Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function App() {
if (!element || !visible || isPanelDocked(panel, panels.width)) return []
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (event.target instanceof HTMLElement && event.target.closest('.forge-history-rename')) return
const modal = [...document.querySelectorAll<HTMLElement>('[role="dialog"]')]
.find((candidate) => candidate !== element && !candidate.contains(element) && candidate.getClientRects().length > 0)
if (modal) return
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/test/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,15 @@ test('uses direct OME for new conversions and keeps direct results readable', as

await waitFor(() => expect(screen.getByText('Viewer connected').parentElement)
.toHaveTextContent('Next conversion · Direct OME-TIFF'))
expect(screen.getByRole('button', { name: 'Convert to direct OME-TIFF' })).toBeVisible()
await waitFor(() => expect(screen.getByRole('button', { name: /Open and approve result/ })
.closest('li')).toHaveAttribute('aria-current', 'step'))
await waitFor(() => expect(screen.getByRole('button', { name: /Open and approve result/ }))
.toHaveAttribute('aria-expanded', 'true'))
const convertStep = screen.getByRole('button', { name: /Create direct OME-TIFF/ })
fireEvent.click(convertStep)
await waitFor(() => expect(screen.getByRole('button', { name: /Create direct OME-TIFF/ }))
.toHaveAttribute('aria-expanded', 'true'))
expect(await screen.findByRole('button', { name: 'Convert to direct OME-TIFF' })).toBeVisible()
const result = await screen.findByRole('region', { name: 'Converted slide result' })
expect(within(result).getByText('Direct OME-TIFF')).toBeVisible()
expect(within(result).getByText('104.9 MB')).toBeVisible()
Expand Down Expand Up @@ -1597,6 +1605,7 @@ test('keeps the original viewer visible while the upload package is still buildi
})

test('views, renames, downloads, and deletes saved conversions from History', async () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 })
const dataset: api.Dataset = {
id: 'history-slide',
displayName: 'History slide.vsi',
Expand Down Expand Up @@ -1672,6 +1681,7 @@ test('views, renames, downloads, and deletes saved conversions from History', as
vi.mocked(api.deleteArtifact).mockResolvedValue(dataset)

render(<App />)
fireEvent.click(await screen.findByRole('button', { name: 'Open slide inspector' }))

const mainViewer = await screen.findByTestId('forge-osd')
expect(mainViewer).toHaveAttribute(
Expand Down Expand Up @@ -1705,6 +1715,11 @@ test('views, renames, downloads, and deletes saved conversions from History', as
'/api/datasets/history-slide/artifacts/history-older/derivative/slide.dzi',
)

fireEvent.click(within(olderCard).getByRole('button', { name: 'Rename' }))
fireEvent.keyDown(within(olderCard).getByRole('textbox', { name: 'Conversion name' }), { key: 'Escape' })
expect(within(olderCard).queryByRole('textbox', { name: 'Conversion name' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'History' })).toBeVisible()

fireEvent.click(within(olderCard).getByRole('button', { name: 'Rename' }))
fireEvent.change(within(olderCard).getByRole('textbox', { name: 'Conversion name' }), {
target: { value: 'Review region' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,17 @@ ON CONFLICT(slide_id) DO UPDATE SET display_name=excluded.display_name,
metadata_revision=excluded.metadata_revision,folder_revision=excluded.folder_revision,
thumbnail_url=excluded.thumbnail_url,tile_url=excluded.tile_url,
remote_updated_at=excluded.remote_updated_at,content_bytes=excluded.content_bytes,
content_sha256=excluded.content_sha256,metadata_json=excluded.metadata_json
content_sha256=excluded.content_sha256,metadata_json=excluded.metadata_json,
download_state=CASE WHEN download_state='READY' AND (
content_revision != excluded.content_revision
OR (excluded.content_bytes > 0 AND download_bytes != excluded.content_bytes)
OR (excluded.content_sha256 != '' AND lower(download_sha256) != lower(excluded.content_sha256))
) THEN 'FAILED' ELSE download_state END,
download_detail=CASE WHEN download_state='READY' AND (
content_revision != excluded.content_revision
OR (excluded.content_bytes > 0 AND download_bytes != excluded.content_bytes)
OR (excluded.content_sha256 != '' AND lower(download_sha256) != lower(excluded.content_sha256))
) THEN 'Viewer content changed; keep a current offline copy' ELSE download_detail END
""")) {
statement.setString(1, slide.id());
statement.setString(2, slide.displayName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,9 @@ static Instant parseViewerInstant(String value) throws IOException {
}

private void ensureSpace(long bytes) throws IOException {
var required = bytes + Math.max(1, bytes / 10);
long required;
try { required = Math.addExact(bytes, Math.max(1, bytes / 10)); }
catch (ArithmeticException error) { throw new IOException("Viewer slide is too large for offline storage", error); }
if (Files.getFileStore(offlineRoot).getUsableSpace() < required) {
throw new IOException("Not enough disk space to keep this slide offline");
}
Expand Down
33 changes: 32 additions & 1 deletion src/test/java/org/pathlab/forge/viewer/ViewerRecoveryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ private static final class Client implements ViewerAuthorizedClient {
volatile String key = A;
byte[] content = CONTENT;
InputStream stream;
long headBytes = CONTENT.length;
String headSha = "";
IOException patchFailure;
String range = "";
int requests;
Expand All @@ -38,7 +40,7 @@ private static final class Client implements ViewerAuthorizedClient {
requests++;
if (method.equals("PATCH")) throw patchFailure;
try {
if (method.equals("HEAD")) return response(200,Map.of("content-length",List.of(String.valueOf(CONTENT.length)),"x-pathlab-sha256",List.of(sha(CONTENT))),new ByteArrayInputStream(new byte[0]));
if (method.equals("HEAD")) return response(200,Map.of("content-length",List.of(String.valueOf(headBytes)),"x-pathlab-sha256",List.of(headSha.isBlank() ? sha(CONTENT) : headSha)),new ByteArrayInputStream(new byte[0]));
} catch (Exception error) { throw new IOException(error); }
if (path.contains("/content")) {
gets++;
Expand Down Expand Up @@ -114,6 +116,35 @@ private static final class Client implements ViewerAuthorizedClient {
}
}

@Test void changedRemoteContentCannotReuseAReadyOfflineCopy() throws Exception {
var client = new Client();
try (var store = new SqliteViewerSyncStore(temp.resolve("changed.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) {
store.upsertRemote(slide());
var retained = service.keepOffline("s1");
var replacement = "replacement OME content".getBytes(StandardCharsets.UTF_8);
store.upsertRemote(new ViewerRemoteSlide("s1", "Original", "", "ready_private", 2, 0, 3, 4,
"/thumb", "/tiles", replacement.length, sha(replacement), Map.of(), Instant.parse("2026-08-12T00:01:00Z")));

assertTrue(Files.exists(retained));
assertTrue(service.offlineFile("s1").isEmpty());
assertEquals("FAILED", store.find("s1").orElseThrow().downloadState());
assertEquals("Viewer content changed; keep a current offline copy", store.find("s1").orElseThrow().downloadDetail());
}
}

@Test void overflowingOfflineSpaceReservationFailsBeforeBodyDownload() throws Exception {
var client = new Client();
client.headBytes = Long.MAX_VALUE;
client.headSha = sha(CONTENT);
try (var store = new SqliteViewerSyncStore(temp.resolve("overflow.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) {
store.upsertRemote(new ViewerRemoteSlide("s1", "Original", "", "ready_private", 1, 0, 3, 4,
"/thumb", "/tiles", Long.MAX_VALUE, sha(CONTENT), Map.of(), Instant.parse("2026-08-12T00:00:00Z")));

assertThrows(IOException.class, () -> service.keepOffline("s1"));
assertEquals(0, client.gets);
}
}

@Test void resumedContentRequiresExactContentRangeBeforeAppending() throws Exception {
var client = new Client(); client.range = "bytes 0-18/19";
try (var store = new SqliteViewerSyncStore(temp.resolve("range.db")); var service = new ViewerSyncService(client,store,temp.resolve("offline"))) {
Expand Down
Loading