Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
Expand All @@ -20,10 +21,12 @@
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
Expand All @@ -38,6 +41,7 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
Expand All @@ -62,6 +66,8 @@ class PreferenceStorageTest {
private final Queue<Runnable> deadlines = new ConcurrentLinkedQueue<>();
private final TestRealm realm = new TestRealm();
private final AtomicLong clock = new AtomicLong();
@TempDir
private Path directory;
private CompletableFuture<ChatPersistence> response;
private PreferenceStorage storage;

Expand All @@ -71,6 +77,10 @@ void setUp() {
when(auth.getUserName()).thenReturn("alice");
response = new CompletableFuture<>();
when(connection.persistence()).thenAnswer(invocation -> response);
when(files.readContents(any(Path.class))).thenAnswer(invocation -> {
String text = files.read(invocation.getArgument(0));
return new PreferenceStorage.FileContents(text.getBytes(StandardCharsets.UTF_8), text);
});
Mockito.doAnswer(invocation -> {
work.add(invocation.getArgument(0));
return null;
Expand Down Expand Up @@ -134,6 +144,7 @@ void testInitialize_UnreadableFile_FailsAndDoesNotOverwrite() throws Exception {
load();

assertFailedWithoutWrites();
assertEquals(State.FAILED, storage.getState());
storage.initialize();
drainWork();
verify(connection).persistence();
Expand All @@ -148,6 +159,137 @@ void testInitialize_InvalidJson_FailsAndDoesNotOverwrite(String content) throws
load();

assertFailedWithoutWrites();
assertEquals(State.CORRUPT, storage.getState());
}

@Test
void testRestoreDefaults_ExplicitRequestBacksUpExactBytesAndPublishesDefaults() throws Exception {
Path path = directory.resolve("alice").resolve("pref.json");
Files.createDirectories(path.getParent());
byte[] original = "{\"chatModel\":\"unfinished".getBytes(StandardCharsets.UTF_8);
Files.write(path, original);
when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8));
Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any());
load();

assertEquals(State.CORRUPT, storage.getState());
verify(files, never()).restore(any(), any(), anyString(), any());
storage.restoreDefaults();
storage.restoreDefaults();
storage.retry();
storage.initialize();
assertEquals(State.RESTORING, storage.getState());
drainWork();
realm.drain();

assertEquals(State.READY, storage.getState());
assertNull(storage.getReadyPreferences().getChatModel());
assertNull(storage.getReadyPreferences().getUserInputs());
verify(files).restore(eq(path), any(), anyString(), any());
try (var filesOnDisk = Files.list(path.getParent())) {
Path backup = filesOnDisk.filter(candidate -> candidate.getFileName().toString().endsWith(".bak"))
.findFirst().orElseThrow();
assertTrue(Arrays.equals(original, Files.readAllBytes(backup)));
}
assertTrue(Files.readString(path).contains("\"chatModel\":null"));
}

@Test
void testRestoreDefaults_FailurePreservesOriginalAndCanBeRetried() throws Exception {
Path path = directory.resolve("alice").resolve("pref.json");
Files.createDirectories(path.getParent());
byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8);
Files.write(path, original);
when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8));
Mockito.doThrow(new IOException("backup failed")).when(files).restore(eq(path), any(), anyString(), any());
load();

storage.restoreDefaults();
drainWork();
realm.drain();

assertEquals(State.RESTORE_FAILED, storage.getState());
assertTrue(Arrays.equals(original, Files.readAllBytes(path)));
verify(files, never()).write(any(), any());

Mockito.doAnswer(invocation -> PreferenceStorage.restoreFile(invocation.getArgument(0),
invocation.getArgument(1), invocation.getArgument(2), invocation.getArgument(3)))
.when(files).restore(eq(path), any(), anyString(), any());
storage.restoreDefaults();
drainWork();
realm.drain();

assertEquals(State.READY, storage.getState());
try (var filesOnDisk = Files.list(path.getParent())) {
Path backup = filesOnDisk.filter(candidate -> candidate.getFileName().toString().endsWith(".bak"))
.findFirst().orElseThrow();
assertTrue(Arrays.equals(original, Files.readAllBytes(backup)));
}
}

@Test
void testRestoreDefaults_ChangedFileIsReloadedInsteadOfReplaced() throws Exception {
Path path = directory.resolve("alice").resolve("pref.json");
Files.createDirectories(path.getParent());
Files.writeString(path, "{invalid");
when(files.read(path)).thenReturn("{invalid");
Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any());
Mockito.doCallRealMethod().when(files).restore(eq(path), any(), anyString(), any());
load();

Files.writeString(path, "{\"chatModel\":\"new-choice\"}");
when(files.read(path)).thenReturn("{\"chatModel\":\"new-choice\"}");
response = new CompletableFuture<>();
storage.restoreDefaults();
drainWork();
response.complete(persistence());
drainWork();
realm.drain();

assertEquals(State.READY, storage.getState());
assertEquals("new-choice", storage.getReadyPreferences().getChatModel());
assertEquals("{\"chatModel\":\"new-choice\"}", Files.readString(path));
try (var filesOnDisk = Files.list(path.getParent())) {
assertTrue(filesOnDisk.noneMatch(candidate -> candidate.getFileName().toString().endsWith(".bak")));
}
}

@Test
void testRestoreDefaults_AccountChangeBeforeWorkerStarts_DoesNotWriteOldAccount() throws Exception {
Path path = directory.resolve("alice").resolve("pref.json");
Files.createDirectories(path.getParent());
byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8);
Files.write(path, original);
when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8));
load();

storage.restoreDefaults();
when(auth.getUserName()).thenReturn("bob");
assertEquals(State.UNAVAILABLE, storage.getState());
drainWork();
realm.drain();

assertTrue(Arrays.equals(original, Files.readAllBytes(path)));
verify(files, never()).restore(any(), any(), anyString(), any());
assertEquals(State.UNAVAILABLE, storage.getReadiness().getValue());
}

@Test
void testRestoreDefaults_DisposalBeforeWorkerStartsDoesNotWrite() throws Exception {
Path path = directory.resolve("alice").resolve("pref.json");
Files.createDirectories(path.getParent());
byte[] original = "{invalid".getBytes(StandardCharsets.UTF_8);
Files.write(path, original);
when(files.read(path)).thenReturn(new String(original, StandardCharsets.UTF_8));
load();

storage.restoreDefaults();
storage.dispose();
drainWork();

assertTrue(Arrays.equals(original, Files.readAllBytes(path)));
verify(files, never()).restore(any(), any(), anyString(), any());
assertEquals(State.DISPOSED, storage.getState());
}

@Test
Expand All @@ -174,6 +316,7 @@ void testInitialize_LegacyGsonExtensions_FailWithoutOverwriting(String content)
load();

assertFailedWithoutWrites();
assertEquals(State.CORRUPT, storage.getState());
}

@Test
Expand Down Expand Up @@ -706,16 +849,16 @@ private void load() {
}

private void assertFailedWithoutWrites() throws IOException {
assertEquals(State.FAILED, storage.getState());
assertEquals(State.FAILED, storage.getReadiness().getValue());
assertTrue(storage.getState() == State.FAILED || storage.getState() == State.CORRUPT);
assertEquals(storage.getState(), storage.getReadiness().getValue());
assertNull(storage.getReadyPreferences());
storage.persist();
verify(files, never()).write(any(), any());
}

private ChatPersistence persistence() {
ChatPersistence result = new ChatPersistence();
result.setPath(Path.of("preferences").toAbsolutePath().toString());
result.setPath(directory.toString());
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
import static org.mockito.ArgumentMatchers.eq;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
Expand Down Expand Up @@ -492,6 +494,130 @@ public void write(Path path, String content) throws IOException {
});
}

@Test
void testCorruptPreferences_RestoreDefaultsBacksUpAndRefreshesConsumers() throws Exception {
byte[] original = "{\"chatModel\":".getBytes(StandardCharsets.UTF_8);
Path preferenceFile = directory.resolve("user").resolve("pref.json");
Files.createDirectories(preferenceFile.getParent());
Files.write(preferenceFile, original);
startAuthenticated(CompletableFuture.completedFuture(persistence()));
awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT);

runOnUi(() -> {
assertEquals(Messages.preferenceCorrupt, statusMessage().getText());
Link restore = Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast)
.filter(link -> "preference-restore-defaults".equals(
link.getData("org.eclipse.swtbot.widget.key")))
.findFirst().orElseThrow();
assertTrue(restore.getEnabled());
assertTrue(Files.exists(preferenceFile));
restore.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event());
});
awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.READY
&& "Agent".equals(service.getActiveModeNameOrId()));

assertTrue(Arrays.equals(original, Files.readAllBytes(findBackup(preferenceFile))));
assertNull(storage.getReadyPreferences().getChatModel());
assertNull(storage.getReadyPreferences().getUserInputs());
runOnUi(() -> assertEquals("", service.getPreviousInput("")));

PreferenceStorage fresh = new PreferenceStorage(connection, auth);
try {
fresh.initialize();
awaitUi(() -> fresh.getReadiness().getValue() == PreferenceStorage.State.READY);
assertNull(fresh.getReadyPreferences().getChatModel());
} finally {
fresh.dispose();
}
}

@Test
void testCorruptPreferences_ModeRetryRemainsIndependent() throws Exception {
CompletableFuture<ConversationMode[]> modes = new CompletableFuture<>();
when(connection.listConversationModes(any())).thenReturn(modes);
writePreferences("{invalid");
startAuthenticated(CompletableFuture.completedFuture(persistence()));
verify(connection, timeout(5000)).listConversationModes(any());
modes.completeExceptionally(new IOException("mode discovery failed"));
awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT
&& service.getModeDiscoveryState() == UserPreferenceService.ModeDiscoveryState.FAILED);

when(connection.listConversationModes(any())).thenReturn(CompletableFuture.completedFuture(
new ConversationMode[] {builtInMode("Ask", "Ask")}));
runOnUi(() -> {
Link retry = Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast)
.filter(link -> "preference-retry".equals(link.getData("org.eclipse.swtbot.widget.key")))
.findFirst().orElseThrow();
assertTrue(retry.getEnabled());
retry.notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event());
});
awaitUi(() -> service.getModeDiscoveryState() == UserPreferenceService.ModeDiscoveryState.READY);

assertEquals(PreferenceStorage.State.CORRUPT, storage.getState());
verify(connection).persistence();
}

@Test
void testRestoreDefaults_FileWorkRunsInBackgroundWhileSwtProcessesEvents() throws Exception {
when(auth.isSignedIn()).thenReturn(true);
when(auth.getUserName()).thenReturn("user");
when(connection.persistence()).thenReturn(CompletableFuture.completedFuture(persistence()));
Path preferenceFile = writePreferences("{invalid");
CountDownLatch restoreStarted = new CountDownLatch(1);
CountDownLatch continueRestore = new CountDownLatch(1);
AtomicBoolean ranOnUiThread = new AtomicBoolean();
runOnUi(() -> {
storage = new PreferenceStorage(connection, auth, DisplayRealm.getRealm(Display.getDefault()),
Executors.newSingleThreadExecutor(), Executors.newSingleThreadScheduledExecutor(), System::nanoTime,
new PreferenceStorage.FileAccess() {
@Override
public String read(Path path) throws IOException {
return Files.readString(path);
}

@Override
public void write(Path path, String content) throws IOException {
Files.writeString(path, content);
}

@Override
public boolean restore(Path path, PreferenceStorage.FileContents original, String content,
java.util.function.BooleanSupplier isCurrent) throws IOException {
ranOnUiThread.set(Display.getCurrent() != null);
restoreStarted.countDown();
try {
if (!continueRestore.await(5, TimeUnit.SECONDS)) {
throw new IOException("Timed out waiting to continue recovery");
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException(exception);
}
return PreferenceStorage.restoreFile(path, original, content, isCurrent);
}
});
createControls();
});
try {
awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.CORRUPT);
runOnUi(() -> Arrays.stream(status.getChildren()).filter(Link.class::isInstance).map(Link.class::cast)
.filter(link -> "preference-restore-defaults".equals(link.getData("org.eclipse.swtbot.widget.key")))
.findFirst().orElseThrow().notifyListeners(SWT.Selection, new org.eclipse.swt.widgets.Event()));
assertTrue(restoreStarted.await(5, TimeUnit.SECONDS));

CompletableFuture<Boolean> uiProcessed = new CompletableFuture<>();
Display.getDefault().asyncExec(() -> uiProcessed.complete(!shell.isDisposed()));
assertTrue(uiProcessed.get(5, TimeUnit.SECONDS));
assertFalse(ranOnUiThread.get());
when(auth.getUserName()).thenReturn("other-user");
assertEquals(PreferenceStorage.State.UNAVAILABLE, storage.getState());
} finally {
continueRestore.countDown();
}
awaitUi(() -> storage.getReadiness().getValue() == PreferenceStorage.State.UNAVAILABLE);
assertEquals("{invalid", Files.readString(preferenceFile));
}

private void startAuthenticated(CompletableFuture<ChatPersistence> rpc) {
when(auth.isSignedIn()).thenReturn(true);
when(auth.getUserName()).thenReturn("user");
Expand Down Expand Up @@ -537,6 +663,12 @@ private Path writePreferences(String content) throws Exception {
return file;
}

private Path findBackup(Path preferenceFile) throws IOException {
try (var files = Files.list(preferenceFile.getParent())) {
return files.filter(path -> path.getFileName().toString().endsWith(".bak")).findFirst().orElseThrow();
}
}

private static void runOnUi(Runnable action) {
AtomicReference<Throwable> failure = new AtomicReference<>();
Display.getDefault().syncExec(() -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ public final class Messages extends NLS {
public static String configureModes;
public static String preferenceLoading;
public static String preferenceLoadFailed;
public static String preferenceCorrupt;
public static String preferenceRestoringDefaults;
public static String preferenceRestoreFailed;
public static String preferenceUnavailable;
public static String preferenceRetry;
public static String preferenceRestoreDefaults;
public static String modeDiscoveryLoading;
public static String modeDiscoveryFailed;
public static String agentMessageWidget_openInBrowserButton;
Expand Down
Loading