diff --git a/apps/demo/e2e/data-search.spec.ts b/apps/demo/e2e/data-search.spec.ts index 73dd70d..b326524 100644 --- a/apps/demo/e2e/data-search.spec.ts +++ b/apps/demo/e2e/data-search.spec.ts @@ -230,3 +230,57 @@ test('csv drop: a 50-row edges fixture derives nodes and swaps the dataset', asy await expect(page.locator(SEARCH_OPTION).first()).toBeVisible({ timeout: 10_000 }); await expect(page.locator(SEARCH_LABEL).first()).toHaveText(/^c17/); }); + +test('a newer data-mode choice supersedes an in-flight CSV preparation', async ({ + page, +}, testInfo) => { + // Make the otherwise-small fixture deterministically asynchronous. Orbit's + // CSV lane consumes File.stream(), so delaying its first chunk leaves time + // for the user to choose another mode before preparation resolves. + await page.addInitScript(() => { + const state = window as typeof window & { __orbitCsvStreamFinished?: boolean }; + state.__orbitCsvStreamFinished = false; + const originalStream = Blob.prototype.stream; + Blob.prototype.stream = function (this: Blob): ReadableStream { + const original = originalStream.call(this); + // Do not delay unrelated Blob consumers used while the app boots. + if (!(this instanceof File) || this.name !== 'superseded.csv') return original; + const reader = original.getReader(); + return new ReadableStream({ + async pull(controller) { + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + const next = await reader.read(); + if (next.done) { + state.__orbitCsvStreamFinished = true; + controller.close(); + } else controller.enqueue(next.value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + }; + }); + + const fixture = testInfo.outputPath('superseded.csv'); + writeFileSync(fixture, 'source,target,weight\na,b,1\nb,c,2\n', 'utf8'); + await gotoReady(page); + + await page.setInputFiles('[data-testid="csv-file-input"]', fixture); + await page.getByTestId('semantic-mode').click(); + await expect(page.getByTestId('m5-panel')).toBeVisible({ timeout: 15_000 }); + + // Wait for EOF rather than a wall-clock approximation, then give the + // preparation promise and React commit a chance to drain. Its completion + // must not replace the newer semantic mode or publish a CSV summary. + await page.waitForFunction( + () => + (window as typeof window & { __orbitCsvStreamFinished?: boolean }) + .__orbitCsvStreamFinished === true, + ); + await page.waitForTimeout(250); + await expect(page.getByTestId('m5-panel')).toBeVisible(); + await expect(page.getByTestId('csv-summary')).toHaveCount(0); +}); diff --git a/apps/demo/e2e/ingestion.spec.ts b/apps/demo/e2e/ingestion.spec.ts index 7295db0..9588181 100644 --- a/apps/demo/e2e/ingestion.spec.ts +++ b/apps/demo/e2e/ingestion.spec.ts @@ -134,3 +134,23 @@ test('stream feed → live meter → commit → isolate via context menu → res await expect(page.locator(SCOPE_STATUS)).toHaveText('full', { timeout: 10_000 }); await expect(page.locator(NODE_COUNT)).toHaveText(fmt(STREAM_NODES)); }); + +test('leaving an active stream run cannot resurrect its cleared progress meter', async ({ page }) => { + // Enough batches to guarantee a paint/yield between the first progress + // receipt and completion; the test cancels immediately after that paint. + await page.goto('/?rows=200000'); + await page.waitForSelector(READY_DOT, { timeout: 60_000 }); + await page.getByTestId('stream-feed').click(); + await expect(page.locator(METER)).toHaveAttribute('data-phase', 'streaming', { + timeout: 15_000, + }); + + await page.getByTestId('semantic-mode').click(); + await expect(page.getByTestId('m5-panel')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(METER)).toHaveCount(0); + + // The abandoned driver emits its terminal `aborted` receipt at the next + // batch boundary. It must remain private to that obsolete run. + await page.waitForTimeout(500); + await expect(page.locator(METER)).toHaveCount(0); +}); diff --git a/apps/demo/e2e/overlay-a11y.spec.ts b/apps/demo/e2e/overlay-a11y.spec.ts index 8997656..6f3b377 100644 --- a/apps/demo/e2e/overlay-a11y.spec.ts +++ b/apps/demo/e2e/overlay-a11y.spec.ts @@ -132,9 +132,19 @@ test('navigator: Tab to toggle, open, arrow through items, Enter focuses a node' }) => { await gotoReady(page); - // The collapsible navigator toggle is the page's first tabbable element. - await page.keyboard.press('Tab'); const toggle = page.locator(NAV_TOGGLE); + // Labels are legitimate keyboard controls and precede the overlay children + // in Graph's DOM. Prove the toggle is reachable by sequential keyboard + // navigation without coupling this test to the current number of labels. + let reachedToggle = false; + for (let tabs = 0; tabs < 64; tabs++) { + await page.keyboard.press('Tab'); + if (await toggle.evaluate((element) => element === document.activeElement)) { + reachedToggle = true; + break; + } + } + expect(reachedToggle, 'Tab should reach the navigator toggle').toBe(true); await expect(toggle).toBeFocused(); await expect(toggle).toHaveAttribute('aria-expanded', 'false'); diff --git a/apps/demo/package.json b/apps/demo/package.json index 29f8c87..dfadf63 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node ../../scripts/check-workspace-worker-asset.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "echo \"demo has no unit tests\" && exit 0", diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index d9b32e2..ce50d28 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -614,6 +614,10 @@ export function App() { /** Last unavailable activation ("