diff options
| author | 2012-01-08 16:41:36 -0800 | |
|---|---|---|
| committer | 2012-01-13 15:27:28 -0800 | |
| commit | a27a3e8ad7d20dea63ef2d5cb8b6ec7e56c20a89 (patch) | |
| tree | a233bcf4a407daa0652fd6229930aaaa348e2f35 | |
| parent | 6a78cd85867c5f22e4e82259b81fab46088331ad (diff) | |
Introduce FileRotator.
Utility that rotates files over time, similar to logrotate. There is
a single "active" file, which is periodically rotated into historical
files, and eventually deleted entirely. Files are stored under a
specific directory with a well-known prefix.
Bug: 5386531
Change-Id: I29f821a881247e50ce0f6f73b20bbd020db39e43
| -rw-r--r-- | core/java/com/android/internal/util/FileRotator.java | 330 | ||||
| -rw-r--r-- | core/tests/coretests/src/com/android/internal/util/FileRotatorTest.java | 428 |
2 files changed, 758 insertions, 0 deletions
diff --git a/core/java/com/android/internal/util/FileRotator.java b/core/java/com/android/internal/util/FileRotator.java new file mode 100644 index 000000000000..3ce95e7d506a --- /dev/null +++ b/core/java/com/android/internal/util/FileRotator.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.util; + +import android.os.FileUtils; + +import com.android.internal.util.FileRotator.Reader; +import com.android.internal.util.FileRotator.Writer; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import libcore.io.IoUtils; + +/** + * Utility that rotates files over time, similar to {@code logrotate}. There is + * a single "active" file, which is periodically rotated into historical files, + * and eventually deleted entirely. Files are stored under a specific directory + * with a well-known prefix. + * <p> + * Instead of manipulating files directly, users implement interfaces that + * perform operations on {@link InputStream} and {@link OutputStream}. This + * enables atomic rewriting of file contents in + * {@link #combineActive(Reader, Writer, long)}. + * <p> + * Users must periodically call {@link #maybeRotate(long)} to perform actual + * rotation. Not inherently thread safe. + */ +public class FileRotator { + private final File mBasePath; + private final String mPrefix; + private final long mRotateAgeMillis; + private final long mDeleteAgeMillis; + + private static final String SUFFIX_BACKUP = ".backup"; + private static final String SUFFIX_NO_BACKUP = ".no_backup"; + + // TODO: provide method to append to active file + + /** + * External class that reads data from a given {@link InputStream}. May be + * called multiple times when reading rotated data. + */ + public interface Reader { + public void read(InputStream in) throws IOException; + } + + /** + * External class that writes data to a given {@link OutputStream}. + */ + public interface Writer { + public void write(OutputStream out) throws IOException; + } + + /** + * Create a file rotator. + * + * @param basePath Directory under which all files will be placed. + * @param prefix Filename prefix used to identify this rotator. + * @param rotateAgeMillis Age in milliseconds beyond which an active file + * may be rotated into a historical file. + * @param deleteAgeMillis Age in milliseconds beyond which a rotated file + * may be deleted. + */ + public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) { + mBasePath = Preconditions.checkNotNull(basePath); + mPrefix = Preconditions.checkNotNull(prefix); + mRotateAgeMillis = rotateAgeMillis; + mDeleteAgeMillis = deleteAgeMillis; + + // ensure that base path exists + mBasePath.mkdirs(); + + // recover any backup files + for (String name : mBasePath.list()) { + if (!name.startsWith(mPrefix)) continue; + + if (name.endsWith(SUFFIX_BACKUP)) { + final File backupFile = new File(mBasePath, name); + final File file = new File( + mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length())); + + // write failed with backup; recover last file + backupFile.renameTo(file); + + } else if (name.endsWith(SUFFIX_NO_BACKUP)) { + final File noBackupFile = new File(mBasePath, name); + final File file = new File( + mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length())); + + // write failed without backup; delete both + noBackupFile.delete(); + file.delete(); + } + } + } + + /** + * Atomically combine data with existing data in currently active file. + * Maintains a backup during write, which is restored if the write fails. + */ + public void combineActive(Reader reader, Writer writer, long currentTimeMillis) + throws IOException { + final String activeName = getActiveName(currentTimeMillis); + + final File file = new File(mBasePath, activeName); + final File backupFile; + + if (file.exists()) { + // read existing data + readFile(file, reader); + + // backup existing data during write + backupFile = new File(mBasePath, activeName + SUFFIX_BACKUP); + file.renameTo(backupFile); + + try { + writeFile(file, writer); + + // write success, delete backup + backupFile.delete(); + } catch (IOException e) { + // write failed, delete file and restore backup + file.delete(); + backupFile.renameTo(file); + throw e; + } + + } else { + // create empty backup during write + backupFile = new File(mBasePath, activeName + SUFFIX_NO_BACKUP); + backupFile.createNewFile(); + + try { + writeFile(file, writer); + + // write success, delete empty backup + backupFile.delete(); + } catch (IOException e) { + // write failed, delete file and empty backup + file.delete(); + backupFile.delete(); + throw e; + } + } + } + + /** + * Read any rotated data that overlap the requested time range. + */ + public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis) + throws IOException { + final FileInfo info = new FileInfo(mPrefix); + for (String name : mBasePath.list()) { + if (!info.parse(name)) continue; + + // read file when it overlaps + if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) { + final File file = new File(mBasePath, name); + readFile(file, reader); + } + } + } + + /** + * Return the currently active file, which may not exist yet. + */ + private String getActiveName(long currentTimeMillis) { + String oldestActiveName = null; + long oldestActiveStart = Long.MAX_VALUE; + + final FileInfo info = new FileInfo(mPrefix); + for (String name : mBasePath.list()) { + if (!info.parse(name)) continue; + + // pick the oldest active file which covers current time + if (info.isActive() && info.startMillis < currentTimeMillis + && info.startMillis < oldestActiveStart) { + oldestActiveName = name; + oldestActiveStart = info.startMillis; + } + } + + if (oldestActiveName != null) { + return oldestActiveName; + } else { + // no active file found above; create one starting now + info.startMillis = currentTimeMillis; + info.endMillis = Long.MAX_VALUE; + return info.build(); + } + } + + /** + * Examine all files managed by this rotator, renaming or deleting if their + * age matches the configured thresholds. + */ + public void maybeRotate(long currentTimeMillis) { + final long rotateBefore = currentTimeMillis - mRotateAgeMillis; + final long deleteBefore = currentTimeMillis - mDeleteAgeMillis; + + final FileInfo info = new FileInfo(mPrefix); + for (String name : mBasePath.list()) { + if (!info.parse(name)) continue; + + if (info.isActive()) { + // found active file; rotate if old enough + if (info.startMillis < rotateBefore) { + info.endMillis = currentTimeMillis; + + final File file = new File(mBasePath, name); + final File destFile = new File(mBasePath, info.build()); + file.renameTo(destFile); + } + } else if (info.endMillis < deleteBefore) { + // found rotated file; delete if old enough + final File file = new File(mBasePath, name); + file.delete(); + } + } + } + + private static void readFile(File file, Reader reader) throws IOException { + final FileInputStream fis = new FileInputStream(file); + final BufferedInputStream bis = new BufferedInputStream(fis); + try { + reader.read(bis); + } finally { + IoUtils.closeQuietly(bis); + } + } + + private static void writeFile(File file, Writer writer) throws IOException { + final FileOutputStream fos = new FileOutputStream(file); + final BufferedOutputStream bos = new BufferedOutputStream(fos); + try { + writer.write(bos); + bos.flush(); + } finally { + FileUtils.sync(fos); + IoUtils.closeQuietly(bos); + } + } + + /** + * Details for a rotated file, either parsed from an existing filename, or + * ready to be built into a new filename. + */ + private static class FileInfo { + public final String prefix; + + public long startMillis; + public long endMillis; + + public FileInfo(String prefix) { + this.prefix = Preconditions.checkNotNull(prefix); + } + + /** + * Attempt parsing the given filename. + * + * @return Whether parsing was successful. + */ + public boolean parse(String name) { + startMillis = endMillis = -1; + + final int dotIndex = name.lastIndexOf('.'); + final int dashIndex = name.lastIndexOf('-'); + + // skip when missing time section + if (dotIndex == -1 || dashIndex == -1) return false; + + // skip when prefix doesn't match + if (!prefix.equals(name.substring(0, dotIndex))) return false; + + try { + startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex)); + + if (name.length() - dashIndex == 1) { + endMillis = Long.MAX_VALUE; + } else { + endMillis = Long.parseLong(name.substring(dashIndex + 1)); + } + + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Build current state into filename. + */ + public String build() { + final StringBuilder name = new StringBuilder(); + name.append(prefix).append('.').append(startMillis).append('-'); + if (endMillis != Long.MAX_VALUE) { + name.append(endMillis); + } + return name.toString(); + } + + /** + * Test if current file is active (no end timestamp). + */ + public boolean isActive() { + return endMillis == Long.MAX_VALUE; + } + } +} diff --git a/core/tests/coretests/src/com/android/internal/util/FileRotatorTest.java b/core/tests/coretests/src/com/android/internal/util/FileRotatorTest.java new file mode 100644 index 000000000000..94d1cb6b5b89 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/util/FileRotatorTest.java @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.util; + +import static android.text.format.DateUtils.DAY_IN_MILLIS; +import static android.text.format.DateUtils.HOUR_IN_MILLIS; +import static android.text.format.DateUtils.MINUTE_IN_MILLIS; +import static android.text.format.DateUtils.SECOND_IN_MILLIS; +import static android.text.format.DateUtils.WEEK_IN_MILLIS; +import static android.text.format.DateUtils.YEAR_IN_MILLIS; + +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.Suppress; +import android.util.Log; + +import com.android.internal.util.FileRotator.Reader; +import com.android.internal.util.FileRotator.Writer; +import com.google.android.collect.Lists; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ProtocolException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; + +import libcore.io.IoUtils; + +/** + * Tests for {@link FileRotator}. + */ +public class FileRotatorTest extends AndroidTestCase { + private static final String TAG = "FileRotatorTest"; + + private File mBasePath; + + private static final String PREFIX = "rotator"; + private static final String ANOTHER_PREFIX = "another_rotator"; + + private static final long TEST_TIME = 1300000000000L; + + // TODO: test throwing rolls back correctly + + @Override + protected void setUp() throws Exception { + super.setUp(); + + mBasePath = getContext().getFilesDir(); + IoUtils.deleteContents(mBasePath); + } + + public void testEmpty() throws Exception { + final FileRotator rotate1 = new FileRotator( + mBasePath, PREFIX, DAY_IN_MILLIS, WEEK_IN_MILLIS); + final FileRotator rotate2 = new FileRotator( + mBasePath, ANOTHER_PREFIX, DAY_IN_MILLIS, WEEK_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // write single new value + rotate1.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + + // assert that one rotator doesn't leak into another + assertReadAll(rotate1, "foo"); + assertReadAll(rotate2); + } + + public void testCombine() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, DAY_IN_MILLIS, WEEK_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // first combine should have empty read, but still write data. + rotate.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "foo"); + + // second combine should replace contents; should read existing data, + // and write final data to disk. + currentTime += SECOND_IN_MILLIS; + reader.reset(); + rotate.combineActive(reader, writer("bar"), currentTime); + reader.assertRead("foo"); + assertReadAll(rotate, "bar"); + } + + public void testRotate() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, DAY_IN_MILLIS, WEEK_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // combine first record into file + rotate.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "foo"); + + // push time a few minutes forward; shouldn't rotate file + reader.reset(); + currentTime += MINUTE_IN_MILLIS; + rotate.combineActive(reader, writer("bar"), currentTime); + reader.assertRead("foo"); + assertReadAll(rotate, "bar"); + + // push time forward enough to rotate file; should still have same data + currentTime += DAY_IN_MILLIS + SECOND_IN_MILLIS; + rotate.maybeRotate(currentTime); + assertReadAll(rotate, "bar"); + + // combine a second time, should leave rotated value untouched, and + // active file should be empty. + reader.reset(); + rotate.combineActive(reader, writer("baz"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "bar", "baz"); + } + + public void testDelete() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, MINUTE_IN_MILLIS, DAY_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // create first record and trigger rotating it + rotate.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + currentTime += MINUTE_IN_MILLIS + SECOND_IN_MILLIS; + rotate.maybeRotate(currentTime); + + // create second record + reader.reset(); + rotate.combineActive(reader, writer("bar"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "foo", "bar"); + + // push time far enough to expire first record + currentTime = TEST_TIME + DAY_IN_MILLIS + (2 * MINUTE_IN_MILLIS); + rotate.maybeRotate(currentTime); + assertReadAll(rotate, "bar"); + + // push further to delete second record + currentTime += WEEK_IN_MILLIS; + rotate.maybeRotate(currentTime); + assertReadAll(rotate); + } + + public void testThrowRestoresBackup() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, MINUTE_IN_MILLIS, DAY_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // first, write some valid data + rotate.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "foo"); + + try { + // now, try writing which will throw + reader.reset(); + rotate.combineActive(reader, new Writer() { + public void write(OutputStream out) throws IOException { + new DataOutputStream(out).writeUTF("bar"); + throw new ProtocolException("yikes"); + } + }, currentTime); + + fail("woah, somehow able to write exception"); + } catch (ProtocolException e) { + // expected from above + } + + // assert that we read original data, and that it's still intact after + // the failed write above. + reader.assertRead("foo"); + assertReadAll(rotate, "foo"); + } + + public void testOtherFilesAndMalformed() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, SECOND_IN_MILLIS, SECOND_IN_MILLIS); + + // should ignore another prefix + touch("another_rotator.1024"); + touch("another_rotator.1024-2048"); + assertReadAll(rotate); + + // verify that broken filenames don't crash + touch("rotator"); + touch("rotator..."); + touch("rotator.-"); + touch("rotator.---"); + touch("rotator.a-b"); + touch("rotator_but_not_actually"); + assertReadAll(rotate); + + // and make sure that we can read something from a legit file + write("rotator.100-200", "meow"); + assertReadAll(rotate, "meow"); + } + + private static final String RED = "red"; + private static final String GREEN = "green"; + private static final String BLUE = "blue"; + private static final String YELLOW = "yellow"; + + public void testQueryMatch() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, HOUR_IN_MILLIS, YEAR_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // rotate a bunch of historical data + rotate.maybeRotate(currentTime); + rotate.combineActive(reader, writer(RED), currentTime); + + currentTime += DAY_IN_MILLIS; + rotate.maybeRotate(currentTime); + rotate.combineActive(reader, writer(GREEN), currentTime); + + currentTime += DAY_IN_MILLIS; + rotate.maybeRotate(currentTime); + rotate.combineActive(reader, writer(BLUE), currentTime); + + currentTime += DAY_IN_MILLIS; + rotate.maybeRotate(currentTime); + rotate.combineActive(reader, writer(YELLOW), currentTime); + + final String[] FULL_SET = { RED, GREEN, BLUE, YELLOW }; + + assertReadAll(rotate, FULL_SET); + assertReadMatching(rotate, Long.MIN_VALUE, Long.MAX_VALUE, FULL_SET); + assertReadMatching(rotate, Long.MIN_VALUE, currentTime, FULL_SET); + assertReadMatching(rotate, TEST_TIME + SECOND_IN_MILLIS, currentTime, FULL_SET); + + // should omit last value, since it only touches at currentTime + assertReadMatching(rotate, TEST_TIME + SECOND_IN_MILLIS, currentTime - SECOND_IN_MILLIS, + RED, GREEN, BLUE); + + // check boundary condition + assertReadMatching(rotate, TEST_TIME + DAY_IN_MILLIS, Long.MAX_VALUE, FULL_SET); + assertReadMatching(rotate, TEST_TIME + DAY_IN_MILLIS + SECOND_IN_MILLIS, Long.MAX_VALUE, + GREEN, BLUE, YELLOW); + + // test range smaller than file + final long blueStart = TEST_TIME + (DAY_IN_MILLIS * 2); + final long blueEnd = TEST_TIME + (DAY_IN_MILLIS * 3); + assertReadMatching(rotate, blueStart + SECOND_IN_MILLIS, blueEnd - SECOND_IN_MILLIS, BLUE); + + // outside range should return nothing + assertReadMatching(rotate, Long.MIN_VALUE, TEST_TIME - DAY_IN_MILLIS); + } + + public void testClockRollingBackwards() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, DAY_IN_MILLIS, YEAR_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // create record at current time + // --> foo + rotate.combineActive(reader, writer("foo"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "foo"); + + // record a day in past; should create a new active file + // --> bar + currentTime -= DAY_IN_MILLIS; + reader.reset(); + rotate.combineActive(reader, writer("bar"), currentTime); + reader.assertRead(); + assertReadAll(rotate, "bar", "foo"); + + // verify that we rewrite current active file + // bar --> baz + currentTime += SECOND_IN_MILLIS; + reader.reset(); + rotate.combineActive(reader, writer("baz"), currentTime); + reader.assertRead("bar"); + assertReadAll(rotate, "baz", "foo"); + + // return to present and verify we write oldest active file + // baz --> meow + currentTime = TEST_TIME + SECOND_IN_MILLIS; + reader.reset(); + rotate.combineActive(reader, writer("meow"), currentTime); + reader.assertRead("baz"); + assertReadAll(rotate, "meow", "foo"); + + // current time should trigger rotate of older active file + rotate.maybeRotate(currentTime); + + // write active file, verify this time we touch original + // foo --> yay + reader.reset(); + rotate.combineActive(reader, writer("yay"), currentTime); + reader.assertRead("foo"); + assertReadAll(rotate, "meow", "yay"); + } + + @Suppress + public void testFuzz() throws Exception { + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, HOUR_IN_MILLIS, DAY_IN_MILLIS); + + final RecordingReader reader = new RecordingReader(); + long currentTime = TEST_TIME; + + // walk forward through time, ensuring that files are cleaned properly + final Random random = new Random(); + for (int i = 0; i < 1024; i++) { + currentTime += Math.abs(random.nextLong()) % DAY_IN_MILLIS; + + reader.reset(); + rotate.combineActive(reader, writer("meow"), currentTime); + + if (random.nextBoolean()) { + rotate.maybeRotate(currentTime); + } + } + + rotate.maybeRotate(currentTime); + + Log.d(TAG, "currentTime=" + currentTime); + Log.d(TAG, Arrays.toString(mBasePath.list())); + } + + public void testRecoverAtomic() throws Exception { + write("rotator.1024-2048", "foo"); + write("rotator.1024-2048.backup", "bar"); + write("rotator.2048-4096", "baz"); + write("rotator.2048-4096.no_backup", ""); + + final FileRotator rotate = new FileRotator( + mBasePath, PREFIX, SECOND_IN_MILLIS, SECOND_IN_MILLIS); + + // verify backup value was recovered; no_backup indicates that + // corresponding file had no backup and should be discarded. + assertReadAll(rotate, "bar"); + } + + private void touch(String... names) throws IOException { + for (String name : names) { + final OutputStream out = new FileOutputStream(new File(mBasePath, name)); + out.close(); + } + } + + private void write(String name, String value) throws IOException { + final DataOutputStream out = new DataOutputStream( + new FileOutputStream(new File(mBasePath, name))); + out.writeUTF(value); + out.close(); + } + + private static Writer writer(final String value) { + return new Writer() { + public void write(OutputStream out) throws IOException { + new DataOutputStream(out).writeUTF(value); + } + }; + } + + private static void assertReadAll(FileRotator rotate, String... expected) throws IOException { + assertReadMatching(rotate, Long.MIN_VALUE, Long.MAX_VALUE, expected); + } + + private static void assertReadMatching( + FileRotator rotate, long matchStartMillis, long matchEndMillis, String... expected) + throws IOException { + final RecordingReader reader = new RecordingReader(); + rotate.readMatching(reader, matchStartMillis, matchEndMillis); + reader.assertRead(expected); + } + + private static class RecordingReader implements Reader { + private ArrayList<String> mActual = Lists.newArrayList(); + + public void read(InputStream in) throws IOException { + mActual.add(new DataInputStream(in).readUTF()); + } + + public void reset() { + mActual.clear(); + } + + public void assertRead(String... expected) { + assertEquals(expected.length, mActual.size()); + + final ArrayList<String> actualCopy = new ArrayList<String>(mActual); + for (String value : expected) { + if (!actualCopy.remove(value)) { + final String expectedString = Arrays.toString(expected); + final String actualString = Arrays.toString(mActual.toArray()); + fail("expected: " + expectedString + " but was: " + actualString); + } + } + } + } +} |