diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index e29bc01b..b92f9ea5 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -16,7 +16,7 @@ command = "bun run dev" [[actions]] name = "Preview gallery" icon = "run" -command = "bun run build && bun install --frozen-lockfile && bun browser-tests/server.ts" +command = "bun run build && bun install --frozen-lockfile && bun dev/server.ts" [[actions]] name = "Check" @@ -24,9 +24,9 @@ icon = "test" command = "bun run check" [[actions]] -name = "Browser tests" +name = "Tests" icon = "test" -command = "bun run build && bun install --frozen-lockfile && bun run test:browser" +command = "bun run build && bun install --frozen-lockfile && bun run test" [[actions]] name = "Build" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ca30af74..56b1e34d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -55,16 +55,14 @@ jobs: run: bun run check:links - name: Check formatting run: bun run format:check - - name: Test source - run: bun run test - name: Verify packed consumer - run: bun run test:package + run: bun run check:package - name: Verify starter integration - run: bun run test:starter + run: bun run check:starter - name: Install browser run: bash scripts/install-test-browser.sh - - name: Test embedding in Chromium - run: bun run test:browser + - name: Test + run: bun run test - name: Verify lockfile is unchanged run: git diff --exit-code -- bun.lock diff --git a/.oxlintrc.json b/.oxlintrc.json index da6f60c4..dd2a0496 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -29,5 +29,33 @@ } } ] - } + }, + "overrides": [ + { + "files": ["tests/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": ["bun:test", "@playwright/test"], + "patterns": [ + { + "group": ["./**", "../**", ".", ".."], + "message": "Use repository-root @/ imports for test fixtures." + }, + { + "group": [ + "@/src/**", + "@/dev/**", + "**/dist/**", + "@altertable/data-app/dist/**" + ], + "message": "Test consumer workflows through @altertable/data-app/, not private implementation modules." + } + ] + } + ] + } + } + ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c339905d..73270d7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,7 @@ Use the Bun and Node.js versions in `.bun-version` and `.node-version`. ```fish bun install --frozen-lockfile +bash scripts/install-test-browser.sh bun run check ``` @@ -36,12 +37,21 @@ fallbacks minimal. ## Verify changes -Tests should tell the package's public usage stories: define an operation, -authorize and query it, compose a view, or embed an app. Exercise published -entries so build and export mistakes are observable. Test private logic in -isolation only when its complexity warrants it, such as cancellation, streaming, -SQL escaping, or authenticated bridge sessions. Avoid tests that repeat trivial -helpers, file layout, or every component prop. +Write high-level public API tests with Vitest in `tests/public-api`. Define an +operation, authorize and query it, compose a view, or embed an app through +`@altertable/data-app/`. Assert consumer-visible results and errors. Supply +data and failures through public dependency injection; do not import private +source, fabricate internal state, or assert implementation details. Prefer queries by role and accessible name, or by label for form fields. Scope +content assertions to the relevant region, status, alert, or dialog; use text +queries for prose with no meaningful role. Do not invent ARIA roles for tests. +Group browser workflows by consumer capability in `apps-*.test.ts`; keep tests +self-contained and setup local until repetition warrants sharing it. + +`bun run test` runs the complete suite against the built package. Browser +workflows launch Chromium through Playwright from Vitest. They use public +consumer fixtures, an isolated local server, and a fresh browser context per +test. Build first; run one file with `bun run test tests/public-api/apps-export.test.ts` +or one workflow with `bun run test -t "workflow name"`. Update the relevant guide when changing public behavior. In documentation prose, write functions as `functionName()` and components as ``. Keep JSDoc for @@ -63,10 +73,9 @@ checks. Build before running consumer tests individually. | `bun run check:links` | Validate local files, images, and heading links | | `bun run check:links:external` | Check external URLs (requires network access) | | `bun run format` | Format source and docs | -| `bun run test` | Verify public contracts and complex isolated logic | -| `bun run test:package` | Verify the npm archive as a consumer | -| `bun run test:starter` | Typecheck, lint, and build the starter | -| `bun run test:browser` | Verify browser interactions in Chromium | +| `bun run test` | Verify high-level public API workflows with Vitest | +| `bun run check:package` | Verify the npm archive as a consumer | +| `bun run check:starter` | Typecheck, lint, and build the starter | | `bun run check:workflows` | Validate workflows and shell scripts | Markdown checks cover all authored `.md` files, including `AGENTS.md` and the @@ -79,18 +88,15 @@ It skips local development URLs and checks reachability without external heading validation. It stays outside required checks because third-party availability and rate limits can cause failures unrelated to a change. -For browser changes, run `bash scripts/install-test-browser.sh` once, then -`bun run test:browser`. - Run `bun run dev` to preview UI examples at `http://127.0.0.1:27418/gallery` and a playground at `/playground` (`dev/playground.tsx`, querying the demo tables `scripts/dev.ts` seeds). It builds once, then rebuilds `dist` when `src` changes; open pages reload on package, fixture, and playground edits. Restart it after changing -`browser-tests/server.ts`; rerun `bun run build` to refresh declarations. Add `?delay=2000` to a host page URL +`dev/server.ts`; rerun `bun run build` to refresh declarations. Add `?delay=2000` to a host page URL to delay its SQL responses and inspect loading states. It needs Docker: SQL runs against a mocked Altertable API ([altertable-mock](https://github.com/altertable-ai/altertable-mock), DuckDB) -started with Testcontainers and stopped on exit. Browser tests keep fixtures. +started with Testcontainers and stopped on exit. Public API tests use their own deterministic consumer fixtures. Open the **Widgets** tab (`/gallery?view=widgets`) for one ready example of each composable data display inside ``. The other tabs cover diff --git a/browser-tests/annotations.spec.ts b/browser-tests/annotations.spec.ts deleted file mode 100644 index b4e79f74..00000000 --- a/browser-tests/annotations.spec.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import { expect, test } from '@playwright/test'; - -test('select widgets and custom elements and deliver numbered feedback through the opaque bridge', async ({ - page, -}, testInfo) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame - .locator('button:visible') - .filter({ hasText: /^Explore revenue$/ }) - .click({ force: true }); - await expect(frame.locator('#result')).not.toHaveText('Chart clicked'); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Compare with last year'); - await expect(frame.getByRole('combobox')).toHaveCount(0); - const composer = await frame - .getByRole('region', { name: 'Annotation editor' }) - .boundingBox(); - const viewport = await page.locator('iframe').boundingBox(); - expect(composer).not.toBeNull(); - expect(composer!.width).toBeLessThanOrEqual(320); - expect(composer!.x).toBeGreaterThanOrEqual(viewport!.x); - expect(composer!.x + composer!.width).toBeLessThanOrEqual( - viewport!.x + viewport!.width - ); - await page.screenshot({ path: testInfo.outputPath('annotations.png') }); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByLabel('Annotation 1', { exact: true })).toBeVisible(); - const output = page.getByLabel('Annotation drafts', { exact: true }); - const drafts = JSON.parse((await output.textContent()) ?? '[]'); - expect(drafts[0].target.id).toBe('monthly-revenue'); - expect(drafts[0].target.queryNames).toEqual(['revenue']); - expect(drafts[0].comment).toBe('Compare with last year'); - await frame.locator('[data-annotation-id="intro"]').click({ force: true }); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Make this shorter'); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByLabel('Annotation 2', { exact: true })).toBeVisible(); - // Host drafts outlive replacement of the iframe document. - await page - .getByRole('button', { name: 'Change javascript' }) - .click({ position: { x: 4, y: 4 } }); - await expect(frame.getByLabel('Annotation 1', { exact: true })).toBeVisible(); - await frame - .getByRole('button', { name: 'Explore revenue', exact: true }) - .click({ position: { x: 4, y: 4 } }); - await expect(frame.locator('#result')).toHaveText('Chart clicked'); -}); - -test('failed delivery retains feedback and supports retry; Escape restores interaction', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations&annotation-error'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame - .locator('[data-annotation-id="customers"]') - .click({ force: true }); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Show active customers'); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByRole('alert')).toHaveText( - 'Could not save annotation. Try again.' - ); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toHaveValue('Show active customers'); - await page.getByRole('button', { name: 'Allow annotations' }).click(); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByLabel('Annotation 1', { exact: true })).toBeVisible(); - await frame - .getByRole('button', { name: 'Annotation selection' }) - .press('Escape'); - await expect( - frame.getByRole('region', { name: 'Annotation editor' }) - ).toHaveCount(0); -}); - -test('hosts without annotation capability keep the normal toolbar', async ({ - page, -}) => { - await page.goto('/bundle-host'); - const frame = page.frameLocator('iframe'); - await expect( - frame.getByRole('button', { name: 'Custom toolbar action' }) - ).toBeVisible(); - await expect( - frame.getByRole('button', { name: 'Annotate', exact: true }) - ).toHaveCount(0); -}); - -test('feedback freezes the displayed filters at selection while results change', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations&annotation-state'); - const frame = page.frameLocator('iframe'); - await expect( - frame.locator('.altertable-data-view-toast[data-state=updating]') - ).toBeVisible(); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame.locator('[data-annotation-id="revenue"]').click({ force: true }); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Compare with last year'); - await frame - .locator('button:visible') - .filter({ hasText: 'Change displayed period' }) - .evaluate(button => (button as HTMLElement).click()); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByLabel('Annotation 1', { exact: true })).toBeVisible(); - const drafts = JSON.parse( - (await page.getByLabel('Annotation drafts').textContent()) ?? '[]' - ); - expect(drafts[0].context.displayedInput).toEqual({ period: 'last-30' }); - expect(drafts[0].context.view).toBe('updating'); - expect(drafts[0].target.text).toContain('42'); -}); - -test('host admission failures explain how to recover without losing the comment', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations&annotation-limit'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame.locator('[data-annotation-id="intro"]').click({ force: true }); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Explain this'); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - await expect(frame.getByRole('alert')).toHaveText( - 'Delete an annotation before adding another.' - ); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toHaveValue('Explain this'); -}); - -test('keyboard selection opens the floating comment and Enter adds feedback', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - const trigger = frame.getByRole('button', { name: 'Annotate', exact: true }); - await trigger.click(); - const layer = frame.getByRole('button', { name: 'Annotation selection' }); - await expect(layer).toBeFocused(); - await layer.press('ArrowRight'); - await layer.press('Enter'); - const comment = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(comment).toBeFocused(); - await comment.fill('Compare with last year'); - await comment.press('Enter'); - await expect(frame.getByLabel('Annotation 1', { exact: true })).toBeVisible(); - await expect(comment).toHaveCount(0); -}); - -test('click coordinates anchor the badge and the bridge carries a real PNG of the area', async ({ - page, -}, testInfo) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const widget = frame.locator('[data-annotation-id="monthly-revenue"]'); - const rect = await widget.evaluate(element => { - const box = element.getBoundingClientRect(); - return { x: box.x, y: box.y, width: box.width, height: box.height }; - }); - await widget.click({ force: true, position: { x: 30, y: 45 } }); - await frame - .getByRole('textbox', { name: 'Annotation text' }) - .fill('Move the legend below the chart'); - await frame - .getByRole('button', { name: 'Add annotation', exact: true }) - .click(); - const output = page.getByLabel('Annotation drafts', { exact: true }); - await expect(output).toContainText('Move the legend below the chart'); - const annotations = JSON.parse((await output.textContent())!); - expect(annotations[0].context.cursor).toEqual({ - x: rect.x + 31, - y: rect.y + 46, - }); - expect(annotations[0].context.anchor.x).toBeCloseTo(31 / rect.width); - expect(annotations[0].context.anchor.y).toBeCloseTo(46 / rect.height); - const screenshot = annotations[0].context.screenshot; - expect(screenshot.mimeType).toBe('image/png'); - expect(screenshot.width).toBeGreaterThan(0); - expect(screenshot.height).toBeGreaterThan(0); - const pixels = Buffer.from(screenshot.dataUrl.split(',')[1], 'base64'); - expect(pixels.byteLength).toBeLessThanOrEqual(262144); - expect(pixels.subarray(0, 8)).toEqual( - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) - ); - await writeFile(testInfo.outputPath('captured-widget.png'), pixels); - const pin = frame.getByRole('button', { name: 'Annotation 1', exact: true }); - const position = await pin.evaluate(element => { - const box = element.getBoundingClientRect(); - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; - }); - const currentRect = await widget.evaluate(element => { - const box = element.getBoundingClientRect(); - return { x: box.x, y: box.y, width: box.width, height: box.height }; - }); - expect(position.x).toBeCloseTo( - currentRect.x + currentRect.width * annotations[0].context.anchor.x, - 0 - ); - expect(position.y).toBeCloseTo( - currentRect.y + currentRect.height * annotations[0].context.anchor.y, - 0 - ); -}); - -test('blank layout areas are not selectable', async ({ page }) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const widget = frame.locator('[data-annotation-id="monthly-revenue"]'); - const bounds = await widget.boundingBox(); - const main = frame.locator('.altertable-app-main'); - const root = await main.boundingBox(); - await main.click({ - force: true, - position: { - x: bounds!.x - root!.x + 4, - y: bounds!.y - root!.y + bounds!.height + 10, - }, - }); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toHaveCount(0); - await expect(frame.locator('.altertable-annotation-outline')).toHaveCount(0); -}); - -test('drag selects a custom screenshot region and reopens its saved outline', async ({ - page, -}, testInfo) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const main = await frame.locator('.altertable-app-main').boundingBox(); - const iframe = await page.locator('iframe').boundingBox(); - const x = main!.x + 20; - const y = Math.max(main!.y, iframe!.y) + 80; - await page.mouse.move(x, y); - await page.mouse.down(); - await page.mouse.move(x + 180, y + 100, { steps: 8 }); - await page.mouse.up(); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill('Align this area'); - await input.press('Enter'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); - const drafts = JSON.parse( - (await page.getByLabel('Annotation drafts').textContent()) ?? '[]' - ); - expect(drafts[0].target.label).toBe('Selected area'); - expect(drafts[0].context.region.width).toBeGreaterThan(0); - expect(drafts[0].context.screenshot.width).toBe(180); - expect(drafts[0].context.screenshot.height).toBe(100); - await writeFile( - testInfo.outputPath('captured-region.png'), - Buffer.from(drafts[0].context.screenshot.dataUrl.split(',')[1], 'base64') - ); - await frame - .getByRole('button', { name: 'Annotation 1', exact: true }) - .click(); - await expect(input).toHaveValue('Align this area'); - await expect(frame.locator('.altertable-annotation-outline')).toHaveCSS( - 'border-radius', - '0px' - ); - await input.press('Escape'); - await expect(input).toHaveCount(0); - await expect( - frame.getByRole('button', { name: 'Annotation selection' }) - ).toBeFocused(); - await expect(frame.locator('.altertable-app-layout')).toHaveAttribute( - 'inert', - '' - ); -}); - -test('annotation layer blocks app controls and Escape closes only the open editor', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const layer = frame.getByRole('button', { name: 'Annotation selection' }); - await expect(layer).toBeFocused(); - await expect(frame.locator('.altertable-app-layout')).toHaveAttribute( - 'inert', - '' - ); - const action = frame - .locator('button:visible') - .filter({ hasText: /^Explore revenue$/ }); - expect( - await action.evaluate(button => { - (button as HTMLElement).focus(); - return document.activeElement === button; - }) - ).toBe(false); - await frame - .locator('button:visible') - .filter({ hasText: /^Explore revenue$/ }) - .click({ force: true }); - await expect(frame.locator('#result')).not.toHaveText('Chart clicked'); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toBeFocused(); - await input.press('Escape'); - await expect(input).toHaveCount(0); - await expect(layer).toBeFocused(); - await layer.press('ArrowRight'); - await layer.press('Enter'); - await expect(input).toBeFocused(); - await input.fill('Change this'); - await input.press('Escape'); - await expect(input).toHaveValue('Change this'); - await input.press('Escape'); - await expect(input).toHaveCount(0); - await expect(layer).toBeFocused(); - await layer.press('Escape'); - await expect(layer).toHaveCount(0); - await expect(frame.locator('.altertable-app-layout')).not.toHaveAttribute( - 'inert' - ); - await expect( - frame.getByRole('button', { name: 'Annotate', exact: true }) - ).toBeFocused(); -}); - -test('custom areas support keyboard selection and Escape cancels only the area', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const layer = frame.getByRole('button', { name: 'Annotation selection' }); - await layer.press('Shift+Enter'); - await expect(frame.locator('[data-selecting]')).toBeVisible(); - await layer.press('Escape'); - await expect(frame.locator('[data-selecting]')).toHaveCount(0); - await expect(layer).toBeFocused(); - await layer.press('Shift+Enter'); - await layer.press('ArrowRight'); - await layer.press('Shift+ArrowDown'); - await layer.press('Enter'); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toBeFocused(); - await input.fill('Space this area evenly'); - await input.press('Enter'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); - const drafts = JSON.parse( - (await page.getByLabel('Annotation drafts').textContent()) ?? '[]' - ); - expect(drafts[0].context.screenshot.width).toBe(110); - expect(drafts[0].context.screenshot.height).toBe(80); - expect(drafts[0].context.region).toBeDefined(); -}); - -test('screenshot failure preserves annotation text and retry recaptures the area', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.locator('body').evaluate(() => { - const original = Object.getOwnPropertyDescriptor( - HTMLCanvasElement.prototype, - 'toDataURL' - )!; - Object.defineProperty(window, 'restoreScreenshotCapture', { - configurable: true, - value: () => { - Object.defineProperty( - HTMLCanvasElement.prototype, - 'toDataURL', - original - ); - }, - }); - HTMLCanvasElement.prototype.toDataURL = () => { - throw new Error('Screenshot unavailable'); - }; - }); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame - .locator('[data-annotation-id="customers"]') - .click({ force: true }); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill('Show active customers'); - await expect(frame.getByRole('alert')).toHaveText( - 'Screenshot capture failed. Your text is preserved.' - ); - await expect( - frame.getByRole('button', { name: 'Add annotation', exact: true }) - ).toBeDisabled(); - await frame.locator('body').evaluate(() => { - ( - window as unknown as Window & { restoreScreenshotCapture: () => void } - ).restoreScreenshotCapture(); - }); - await frame.getByRole('button', { name: 'Retry screenshot' }).click(); - await expect(input).toHaveValue('Show active customers'); - await expect( - frame.getByRole('button', { name: 'Add annotation', exact: true }) - ).toBeEnabled(); - await input.press('Enter'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); -}); - -test('an open editor blocks background selection and uses a dashed widget outline', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await frame - .locator('[data-annotation-id="customers"]') - .click({ force: true }); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill('Keep this draft'); - const outline = frame.locator('.altertable-annotation-outline'); - const initial = await outline.boundingBox(); - await frame - .locator('[data-annotation-id="monthly-revenue"]') - .click({ force: true }); - await expect(input).toHaveValue('Keep this draft'); - await expect(input).toBeFocused(); - await expect(outline).toHaveCSS('border-style', 'dashed'); - expect(await outline.boundingBox()).toEqual(initial); - await expect( - frame.getByRole('button', { name: 'Select area', exact: true }) - ).toHaveCount(0); -}); - -test('custom areas capture the document margins beyond the app container', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const iframe = await page.locator('iframe').boundingBox(); - // Exercise reserved-gutter geometry on platforms with overlay scrollbars too. - await frame.locator('body').evaluate(element => { - element.style.width = `${document.documentElement.clientWidth - 16}px`; - }); - const body = await frame.locator('body').boundingBox(); - const container = await frame.locator('.altertable-app-main').boundingBox(); - const left = iframe!.x + 2; - const clientWidth = await frame - .locator('body') - .evaluate(() => document.documentElement.clientWidth); - const right = iframe!.x + clientWidth - 2; - const top = iframe!.y + 80; - await page.mouse.move(left, top); - await page.mouse.down(); - await page.mouse.move(right, top + 100, { steps: 6 }); - await page.mouse.up(); - const outline = await frame - .locator('.altertable-annotation-outline') - .boundingBox(); - expect(outline!.x).toBeLessThan(container!.x); - expect(outline!.x + outline!.width).toBeGreaterThan( - container!.x + container!.width - ); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill('Use the surrounding space'); - await input.press('Enter'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); - const drafts = JSON.parse( - (await page.getByLabel('Annotation drafts').textContent()) ?? '[]' - ); - expect(drafts[0].context.rect.x).toBeCloseTo(2, 0); - expect(drafts[0].context.rect.width).toBeCloseTo( - body!.x + body!.width - left, - 0 - ); - expect(drafts[0].context.screenshot.height).toBeGreaterThan(0); - const selection = frame.getByRole('button', { name: 'Annotation selection' }); - await expect(selection).toHaveCSS('outline-style', 'none'); -}); - -test('annotation mode has no selection toolbar and blocks background wheel scrolling', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - await expect( - frame.getByRole('button', { name: 'Select area', exact: true }) - ).toHaveCount(0); - await expect( - frame.getByRole('button', { name: 'Scroll app', exact: true }) - ).toHaveCount(0); - const before = await frame.locator('body').evaluate(() => window.scrollY); - const iframe = await page.locator('iframe').boundingBox(); - await page.mouse.move( - iframe!.x + iframe!.width / 2, - iframe!.y + iframe!.height / 2 - ); - await page.mouse.wheel(0, 400); - expect(await frame.locator('body').evaluate(() => window.scrollY)).toBe( - before - ); -}); - -test('keyboard navigation skips hidden targets and hidden duplicate IDs', async ({ - page, -}) => { - await page.goto('/bundle-host?annotations'); - const frame = page.frameLocator('iframe'); - await frame.locator('.altertable-app-main').evaluate(root => { - const hiddenTargets = document.createElement('div'); - hiddenTargets.innerHTML = ` - -
Hidden visibility
-
Hidden opacity
- -
`; - root.prepend(hiddenTargets); - }); - await frame.getByRole('button', { name: 'Annotate', exact: true }).click(); - const selection = frame.getByRole('button', { name: 'Annotation selection' }); - const announcement = frame.locator('output[aria-live=polite]'); - await selection.press('ArrowRight'); - await expect(announcement).toHaveText('Revenue by month, 1 of 3'); - await selection.press('ArrowRight'); - await expect(announcement).toHaveText('Customers, 2 of 3'); - await selection.press('ArrowRight'); - await expect(announcement).toHaveText('Introduction, 3 of 3'); - await selection.press('ArrowRight'); - await expect(announcement).toHaveText('Revenue by month, 1 of 3'); - await expect(frame.locator('.altertable-annotation-hint')).not.toHaveText( - 'Some items cannot be annotated.' - ); - await selection.press('Enter'); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill('Visible target only'); - await input.press('Enter'); - const output = page.getByLabel('Annotation drafts', { exact: true }); - await expect(output).toContainText('Visible target only'); - const drafts = JSON.parse((await output.textContent()) ?? '[]'); - expect(drafts[0].target.id).toBe('monthly-revenue'); - expect(drafts[0].context.screenshot).toBeDefined(); -}); diff --git a/browser-tests/appearance.spec.ts b/browser-tests/appearance.spec.ts deleted file mode 100644 index 7adca89d..00000000 --- a/browser-tests/appearance.spec.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -async function color( - page: Page, - selector: string, - property: string, - pseudo?: string -) { - return page.locator(selector).evaluate( - (element, { property, pseudo }) => { - const canvas = document.createElement('canvas'); - canvas.width = canvas.height = 1; - const context = canvas.getContext('2d')!; - context.fillStyle = getComputedStyle(element, pseudo).getPropertyValue( - property - ); - context.fillRect(0, 0, 1, 1); - return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3); - }, - { property, pseudo } - ); -} - -function contrast(first: number[], second: number[]) { - function luminance(rgb: number[]) { - const linear = rgb.map(value => { - const channel = value / 255; - return channel <= 0.04045 - ? channel / 12.92 - : ((channel + 0.055) / 1.055) ** 2.4; - }); - return linear[0]! * 0.2126 + linear[1]! * 0.7152 + linear[2]! * 0.0722; - } - const a = luminance(first), - b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); -} - -test('component injection preserves host controls and supports author overrides in either order', async ({ - page, -}) => { - await page.goto('/appearance'); - await expect(page.getByRole('button', { name: 'Host control' })).toHaveCSS( - 'cursor', - 'default' - ); - await expect(page.getByRole('button', { name: 'Host control' })).toHaveCSS( - 'font-size', - '17px' - ); - await expect(page.locator('.host-tabs .react-aria-Tab')).toHaveCSS( - 'border-bottom-width', - '0px' - ); - await expect(page.locator('body')).toHaveCSS('margin-top', '8px'); - await page.evaluate(() => { - window.setAppearance({ - theme: 'dark', - accentColor: '#ffcc00', - density: 'spacious', - }); - document.documentElement.setAttribute('data-override', ''); - document - .querySelector('.altertable-button')! - .setAttribute('data-override', ''); - }); - await expect( - page.getByRole('button', { name: 'Action', exact: true }) - ).toHaveCSS('background-color', 'rgb(12, 34, 56)'); - await expect( - page.getByRole('button', { name: 'Action', exact: true }) - ).toHaveCSS('cursor', 'crosshair'); - expect( - await page.evaluate(() => - getComputedStyle(document.documentElement) - .getPropertyValue('--atbl-accent') - .trim() - ) - ).toBe('#123456'); - expect( - await page.evaluate(() => - getComputedStyle(document.documentElement) - .getPropertyValue('--atbl-space-md') - .trim() - ) - ).toBe('23px'); - expect( - await page.evaluate(() => - document.documentElement.style.getPropertyValue('--atbl-accent') - ) - ).toBe(''); - await page.addStyleTag({ - content: ':root[data-override] { --atbl-cursor-action: grab; }', - }); - await expect( - page.getByRole('button', { name: 'Action', exact: true }) - ).toHaveCSS('cursor', 'grab'); -}); - -test('native, React Aria, checkbox and chart controls share cursor and keyboard focus policies', async ({ - page, -}) => { - await page.goto('/appearance'); - await expect( - page.getByRole('button', { name: 'Action', exact: true }) - ).toHaveCSS('cursor', 'pointer'); - await expect(page.getByRole('button', { name: 'Chart action' })).toHaveCSS( - 'cursor', - 'pointer' - ); - await expect(page.getByRole('button', { name: 'Definition' })).toHaveCSS( - 'cursor', - 'help' - ); - await expect(page.getByRole('button', { name: 'Disabled action' })).toHaveCSS( - 'cursor', - 'default' - ); - await expect( - page.getByRole('checkbox', { name: 'Disabled checkbox' }) - ).toHaveCSS('cursor', 'default'); - await expect(page.getByRole('option', { name: 'Disabled option' })).toHaveCSS( - 'cursor', - 'default' - ); - await expect(page.getByRole('option', { name: 'Focused option' })).toHaveCSS( - 'outline-offset', - '-1px' - ); - for (const name of ['Action', 'Chart action']) { - await page.keyboard.press('Tab'); - await page.getByRole('button', { name, exact: true }).focus(); - await expect(page.getByRole('button', { name, exact: true })).toHaveCSS( - 'outline-style', - 'solid' - ); - await expect(page.getByRole('button', { name, exact: true })).toHaveCSS( - 'outline-offset', - '2px' - ); - } - await page - .getByRole('checkbox', { name: 'Selected Muted explanation' }) - .focus(); - await expect(page.locator('.altertable-checkbox').first()).toHaveCSS( - 'outline-style', - 'solid' - ); - await page.emulateMedia({ forcedColors: 'active' }); - await expect(page.getByRole('option', { name: 'Focused option' })).toHaveCSS( - 'outline-style', - 'solid' - ); -}); - -test('preset themes and brand foregrounds have sufficient contrast and theme portals', async ({ - page, -}) => { - await page.goto('/appearance'); - for (const baseColor of ['neutral', 'slate', 'warm'] as const) { - for (const theme of ['light', 'dark'] as const) { - await page.evaluate(options => window.setAppearance(options), { - baseColor, - theme, - }); - const background = await color( - page, - '.altertable-metric-widget', - 'background-color' - ); - const text = await color(page, '.altertable-metric-widget', 'color'); - const muted = await color(page, '.altertable-checkbox small', 'color'); - expect(contrast(text, background)).toBeGreaterThanOrEqual(4.5); - const focus = await color( - page, - '.altertable-tabs .react-aria-Tab', - 'outline-color' - ); - expect(contrast(focus, background)).toBeGreaterThanOrEqual(3); - const mark = await color( - page, - '.altertable-selectable-bars-bar', - 'background-color' - ); - expect(contrast(mark, background)).toBeGreaterThanOrEqual(3); - expect(await color(page, '[role=tooltip]', 'background-color')).toEqual( - background - ); - expect(contrast(muted, background)).toBeGreaterThanOrEqual(4.5); - } - } - for (const options of [ - { theme: 'light', accentColor: '#ffcc00' }, - { theme: 'light', accentColor: '#112233' }, - { theme: 'light', accentColor: 'gold' }, - { theme: 'light', accentColor: 'rgb(12, 34, 56)' }, - { theme: 'dark', accentColor: '#112233' }, - { theme: 'dark', darkAccentColor: '#112233' }, - ] as const) { - await page.evaluate(options => window.setAppearance(options), options); - const tick = await color( - page, - '.altertable-selection-mark', - 'border-top-color', - '::after' - ); - const accent = await color( - page, - '.altertable-selection-mark', - 'background-color' - ); - expect(contrast(tick, accent)).toBeGreaterThanOrEqual(4.5); - } -}); - -test('palette changes populate all eight chart slots and remove stale colors', async ({ - page, -}) => { - await page.goto('/appearance'); - await page.evaluate(() => - window.setAppearance({ chartColors: ['#112233', '#aabbcc'] }) - ); - for (let index = 1; index <= 8; index++) - expect( - await color(page, `[data-chart='${index}']`, 'background-color') - ).toEqual(index % 2 ? [17, 34, 51] : [170, 187, 204]); - await page.evaluate(() => - window.setAppearance({ chartColors: ['#445566'], theme: 'dark' }) - ); - for (let index = 1; index <= 8; index++) - expect( - await color(page, `[data-chart='${index}']`, 'background-color') - ).toEqual([143, 153, 163]); - await page.evaluate(() => window.setAppearance({})); - expect(await color(page, "[data-chart='7']", 'background-color')).toEqual([ - 40, 95, 192, - ]); - expect(await color(page, "[data-chart='8']", 'background-color')).toEqual([ - 169, 83, 25, - ]); -}); - -test('system theme changes update presets and cleanup restores prior document state', async ({ - page, -}) => { - await page.emulateMedia({ colorScheme: 'light' }); - await page.goto('/appearance'); - await page.evaluate(() => { - document.documentElement.style.setProperty( - '--atbl-input-accent', - '#abcdef' - ); - document.documentElement.setAttribute('data-atbl-density', 'compact'); - window.setAppearance({ theme: 'system', density: 'spacious' }); - }); - await expect(page.locator('html')).toHaveAttribute( - 'data-atbl-theme', - 'light' - ); - await page.emulateMedia({ colorScheme: 'dark' }); - await expect(page.locator('html')).toHaveAttribute('data-atbl-theme', 'dark'); - await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark'); - await page.evaluate(() => window.clearAppearance()); - await expect(page.locator('html')).not.toHaveAttribute( - 'data-atbl-appearance' - ); - await expect(page.locator('html')).not.toHaveAttribute('data-atbl-theme'); - await expect(page.locator('html')).toHaveAttribute( - 'data-atbl-density', - 'compact' - ); - expect( - await page.evaluate(() => - document.documentElement.style.getPropertyValue('--atbl-input-accent') - ) - ).toBe('#abcdef'); - await page.emulateMedia({ colorScheme: 'light' }); - await expect(page.locator('html')).not.toHaveAttribute('data-atbl-theme'); -}); - -test('default grids retain a single column', async ({ page }) => { - await page.goto('/appearance'); - const first = await page - .getByText('First grid item', { exact: true }) - .boundingBox(); - const second = await page - .getByText('Second grid item', { exact: true }) - .boundingBox(); - expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height); - expect(second!.x).toBe(first!.x); - expect(second!.width).toBe(first!.width); -}); - -test('computed defaults compose local inputs and preserve explicit ancestor overrides', async ({ - page, -}) => { - await page.goto('/appearance'); - await page.evaluate(() => { - const button = - document.querySelector('.altertable-button')!; - button.dataset.atblInternalSurface = 'control'; - button.style.setProperty('--atbl-focus-color', '#ff0000'); - button.style.setProperty('--atbl-accent', '#ff0000'); - button.setAttribute('data-focus-visible', ''); - button.setAttribute('aria-pressed', 'true'); - }); - const button = page.getByRole('button', { name: 'Action', exact: true }); - await expect(button).toHaveCSS('outline-color', 'rgb(255, 0, 0)'); - await expect - .poll(() => - color(page, '.altertable-button:not(:disabled)', 'background-color') - ) - .toEqual([255, 224, 224]); - await page.evaluate(() => window.setAppearance({ theme: 'dark' })); - await expect - .poll(() => - color(page, '.altertable-button:not(:disabled)', 'background-color') - ) - .toEqual([81, 27, 30]); - await page.addStyleTag({ - content: - ':root { --atbl-focus-outline: 3px dashed blue; --atbl-control-selected-surface: #123456; }', - }); - await expect(button).toHaveCSS('outline-width', '3px'); - await expect(button).toHaveCSS('outline-style', 'dashed'); - await expect(button).toHaveCSS('outline-color', 'rgb(0, 0, 255)'); - await expect(button).toHaveCSS('background-color', 'rgb(18, 52, 86)'); -}); - -test('local layout spacing and overlay selection defaults resolve at consumption', async ({ - page, -}) => { - await page.goto('/appearance'); - await page.evaluate(() => { - const grid = document.querySelector('.altertable-grid')!; - grid.style.setProperty('--atbl-space-md', '41px'); - grid.style.setProperty('--atbl-space-lg', '47px'); - const overlay = document.querySelector( - '.altertable-combobox-popover' - )!; - overlay.style.setProperty('--atbl-accent', '#ff0000'); - const option = overlay.querySelector('[data-focus-visible]')!; - option.dataset.atblInternalSurface = 'option'; - option.setAttribute('data-selected', ''); - }); - await expect(page.locator('.altertable-grid')).toHaveCSS('row-gap', '41px'); - expect( - await color(page, "[role='option'][data-selected]", 'background-color') - ).toEqual([255, 224, 224]); -}); - -test('native and Aria state paint excludes disabled hover and keeps busy geometry', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - const disabled = page.getByRole('button', { - name: 'Disabled categories: HTTP', - exact: true, - }); - const before = await disabled.evaluate( - element => getComputedStyle(element).backgroundColor - ); - await expect(disabled).toHaveCSS('opacity', '0.5'); - await disabled.hover(); - await expect(disabled).toHaveCSS('background-color', before); - await page.goto('/appearance'); - const action = page.getByRole('button', { name: 'Action', exact: true }); - const bounds = await action.boundingBox(); - await action.evaluate(element => element.setAttribute('aria-busy', 'true')); - await expect(action).toHaveCSS('cursor', 'progress'); - expect(await action.boundingBox()).toEqual(bounds); - await action.evaluate(element => element.setAttribute('disabled', '')); - await expect(action).toHaveCSS('cursor', 'default'); -}); - -test('search fields apply hover borders only while enabled', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - const field = page.locator('.altertable-search-input-wrap:visible').first(); - await field.evaluate(element => - (element as HTMLElement).style.setProperty( - '--atbl-control-hover-border', - 'rgb(255, 0, 0)' - ) - ); - await field.hover(); - await expect(field).toHaveCSS('border-top-color', 'rgb(255, 0, 0)'); - await page.mouse.move(0, 0); - const idleBorder = await field.evaluate( - element => getComputedStyle(element).borderTopColor - ); - await field - .locator('input') - .evaluate(element => ((element as HTMLInputElement).disabled = true)); - await field.hover(); - await expect(field).toHaveCSS('border-top-color', idleBorder); -}); - -test('density changes control geometry without shrinking typography', async ({ - page, -}) => { - await page.goto('/appearance'); - const button = page.getByRole('button', { name: 'Action', exact: true }); - const heights: number[] = []; - for (const density of ['compact', 'comfortable', 'spacious'] as const) { - await page.evaluate(density => window.setAppearance({ density }), density); - heights.push((await button.boundingBox())!.height); - await expect(button).toHaveCSS('font-size', '13px'); - } - heights.forEach((height, index) => - expect(height).toBeCloseTo([34, 38, 42][index]!, 2) - ); -}); diff --git a/browser-tests/authoring.spec.ts b/browser-tests/authoring.spec.ts deleted file mode 100644 index 28b86016..00000000 --- a/browser-tests/authoring.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { expect, test } from '@playwright/test'; - -const failure = { error: 'Fixture unavailable' }; - -test('single-file hosted example queries through the host and preserves displayed filters', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - const requests: { statement: string; limit: number; host: boolean }[] = []; - let fail = false; - let release: (() => void) | undefined; - let gate = new Promise(resolve => { - release = resolve; - }); - await page.route('**/api/sql', async route => { - const payload = route.request().postDataJSON(); - requests.push({ - ...payload, - host: route.request().frame() === page.mainFrame(), - }); - await gate; - if (fail) await route.fulfill({ status: 503, json: failure }); - else await route.continue(); - }); - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - await expect(app.getByText('Loading data')).toBeVisible(); - await expect( - app.locator('.altertable-skeleton[data-inline]').first() - ).toHaveCSS('vertical-align', 'middle'); - await expect( - app.getByRole('heading', { name: 'Sample counts', exact: true }) - ).toBeVisible(); - await expect(app.getByText('Total samples', { exact: true })).toBeVisible(); - await expect( - app.getByText('Sum of the fixture counts in the selected groups.', { - exact: true, - }) - ).toBeVisible(); - await expect( - app.getByRole('heading', { name: 'Counts by group', exact: true }) - ).toBeVisible(); - await expect( - app.getByRole('row', { name: 'Alpha 3', exact: true }) - ).toHaveCount(0); - await expect( - app.getByRole('button', { name: 'Present story', exact: true }) - ).toBeDisabled(); - const stableActions = ['Explore data', 'Export CSV', 'Present story'].map( - name => app.getByRole('button', { name, exact: true }) - ); - await expect(stableActions[1]!).toBeDisabled(); - const loadingPositions = await Promise.all( - stableActions.map(action => action.boundingBox()) - ); - release!(); - gate = Promise.resolve(); - await expect( - app - .getByRole('row', { name: 'Alpha 3', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); - await expect( - app - .getByRole('row', { name: 'Beta 0', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); - await expect(stableActions[1]!).toBeEnabled(); - await expect(stableActions[2]!).toBeEnabled(); - expect( - await Promise.all(stableActions.map(action => action.boundingBox())) - ).toEqual(loadingPositions); - await expect(app.locator('style[data-altertable-styles]')).toHaveCount(1); - const width = await app - .locator('html') - .evaluate(element => element.clientWidth); - expect(width).toBeLessThanOrEqual(page.viewportSize()!.width); - const queryCountBeforeStory = requests.length; - await app.getByRole('button', { name: 'Present story', exact: true }).click(); - const story = app.getByRole('dialog'); - await expect( - story.getByRole('heading', { name: 'Total samples: 3', exact: true }) - ).toBeVisible(); - await story.getByRole('button', { name: 'Next step', exact: true }).click(); - await expect( - story.getByRole('heading', { name: 'Counts by group', exact: true }) - ).toBeVisible(); - await expect( - story.getByRole('button', { name: 'Explore sources', exact: true }) - ).toBeVisible(); - await expect(story).toHaveCSS('opacity', '1'); - await page - .locator('iframe') - .screenshot({ path: test.info().outputPath('story.png') }); - await test.info().attach('Presented story', { - path: test.info().outputPath('story.png'), - contentType: 'image/png', - }); - await story - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - expect(requests.length).toBe(queryCountBeforeStory); - const group = app.getByRole('searchbox', { name: 'Group', exact: true }); - gate = new Promise(resolve => { - release = resolve; - }); - await group.fill('Beta'); - await expect( - app.getByText('Showing all groups', { exact: true }) - ).toBeVisible(); - await expect( - app.getByText('Showing all groups while loading group Beta…') - ).toBeVisible(); - release!(); - gate = Promise.resolve(); - await expect( - app.getByText('Showing group Beta', { exact: true }) - ).toBeVisible(); - await expect( - app - .getByRole('row', { name: 'Alpha 3', exact: true }) - .filter({ visible: true }) - ).toHaveCount(0); - fail = true; - await group.fill('Alpha'); - await expect( - app.getByText('Showing group Beta', { exact: true }) - ).toBeVisible(); - await expect( - app.getByText( - 'Couldn’t refresh. Showing group Beta while group Alpha is unavailable.' - ) - ).toBeVisible(); - await app.getByRole('button', { name: 'Present story', exact: true }).click(); - await expect( - story.getByRole('heading', { name: 'Total samples: 0', exact: true }) - ).toBeVisible(); - await expect(story).toHaveAccessibleDescription( - 'Demonstration values for group Beta.' - ); - await story - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - fail = false; - await app.getByRole('button', { name: 'Try again', exact: true }).click(); - await expect( - app.getByText('Showing group Alpha', { exact: true }) - ).toBeVisible(); - await group.fill('missing'); - await expect( - app.getByText('No matching groups', { exact: true }) - ).toBeVisible(); - await expect( - app.getByRole('button', { name: 'Present story', exact: true }) - ).toBeDisabled(); - await group.fill("O'Reilly"); - await expect.poll(() => requests.at(-1)?.statement).toContain("O''Reilly"); - await expect( - app.getByText('No matching groups', { exact: true }) - ).toBeVisible(); - await group.fill(''); - await expect( - app - .getByRole('row', { name: 'Alpha 3', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); - const before = requests.length; - await app.getByRole('button', { name: 'Refresh data', exact: true }).click(); - await expect.poll(() => requests.length).toBeGreaterThan(before); - await expect( - app - .getByRole('row', { name: 'Beta 0', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); - expect( - requests.every( - request => - request.host && - request.limit === 10 && - request.statement.includes('LIMIT 10') - ) - ).toBe(true); - await expect( - app.getByRole('button', { name: 'Refresh data', exact: true }) - ).toBeVisible(); - await page.locator('iframe').screenshot({ - path: test.info().outputPath('example.png'), - }); - await test.info().attach('Rendered example', { - path: test.info().outputPath('example.png'), - contentType: 'image/png', - }); - expect(errors).toEqual([]); -}); - -test('hosted initial query error recovers by retry', async ({ page }) => { - let fail = true; - await page.route('**/api/sql', async route => { - if (fail) await route.fulfill({ status: 503, json: failure }); - else await route.continue(); - }); - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - await expect( - app.getByText('Couldn’t load results', { exact: true }) - ).toBeVisible(); - await expect( - app.getByRole('button', { name: 'Export CSV', exact: true }) - ).toBeDisabled(); - await expect( - app.getByRole('button', { name: 'Present story', exact: true }) - ).toBeDisabled(); - fail = false; - await app.getByRole('button', { name: 'Retry', exact: true }).click(); - await expect( - app - .getByRole('row', { name: 'Alpha 3', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); -}); - -test('local starter mounts, queries its server, refreshes and retries a failed check', async ({ - page, - baseURL, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - let fail = false; - let release!: () => void; - let gate = new Promise(resolve => { - release = resolve; - }); - await page.route('**/api/data/connection', async route => { - await gate; - if (fail) - await route.fulfill({ - status: 503, - json: { - error: { code: 'source_unavailable', message: 'Fixture unavailable' }, - }, - }); - else await route.continue(); - }); - const local = new URL(baseURL!); - local.port = String(Number(local.port) + 2); - await page.goto(local.href); - await expect( - page.getByRole('heading', { name: 'Checking connection…', exact: true }) - ).toBeVisible(); - release(); - gate = Promise.resolve(); - await expect( - page.getByRole('heading', { name: 'Connected', exact: true }) - ).toBeVisible(); - await expect(page.locator('style[data-altertable-styles]')).toHaveCount(1); - fail = true; - await page - .getByRole('button', { name: 'Check connection', exact: true }) - .click(); - await expect( - page.getByText('An earlier query succeeded, but the latest check failed.', { - exact: false, - }) - ).toBeVisible(); - fail = false; - await page.getByRole('button', { name: 'Try again', exact: true }).click(); - await expect( - page.getByRole('heading', { name: 'Connected', exact: true }) - ).toBeVisible(); - fail = true; - await page.reload(); - await expect( - page.getByRole('heading', { name: 'Connection not verified', exact: true }) - ).toBeVisible(); - fail = false; - await page.getByRole('button', { name: 'Try again', exact: true }).click(); - await expect( - page.getByRole('heading', { name: 'Connected', exact: true }) - ).toBeVisible(); - await page.screenshot({ - path: test.info().outputPath('example.png'), - fullPage: true, - }); - await test.info().attach('Rendered example', { - path: test.info().outputPath('example.png'), - contentType: 'image/png', - }); - expect(errors).toEqual([]); -}); diff --git a/browser-tests/bridge-source.spec.ts b/browser-tests/bridge-source.spec.ts deleted file mode 100644 index d20d3574..00000000 --- a/browser-tests/bridge-source.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('opaque bundle uses typed routes and virtual URL state without rerunning on handler changes', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - const apiFrames: string[] = []; - page.on('request', request => { - if ( - request.url().includes('/api/data/') || - request.url().endsWith('/api/sql') - ) - apiFrames.push(request.frame() === page.mainFrame() ? 'host' : 'app'); - }); - await page.goto('/bundle-host?period=last-30#totals'); - const app = page.frameLocator('iframe'); - await expect(page.locator('iframe')).toBeVisible(); - await expect(page.locator('iframe')).toHaveClass('app-frame'); - await expect(app.locator('body')).toHaveAttribute('data-bundle-version', '1'); - await expect(page.locator('iframe')).toHaveAttribute( - 'sandbox', - 'allow-scripts' - ); - await expect(app.locator('#location')).toHaveText('period=last-30#totals'); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":1'); - await app.getByRole('button', { name: 'Data query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"data":true'); - await expect(app.locator('#result')).toContainText( - 'SELECT 1 AS connection_check' - ); - await expect(app.locator('#result')).toContainText('sql-query'); - await app.getByRole('button', { name: 'Denied query', exact: true }).click(); - await expect(app.locator('#result')).toHaveText( - '{"publicError":true,"code":"forbidden"}' - ); - await page.locator('iframe').evaluate(frame => { - frame.dataset.identity = 'original'; - }); - await page.getByRole('button', { name: 'Change handler' }).click(); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); - await expect(page.locator('iframe')).toHaveAttribute( - 'data-identity', - 'original' - ); - await app.getByRole('button', { name: 'Last 7 days' }).click(); - await expect(page).toHaveURL(/period=last-7#totals$/); - await page.goBack(); - await expect(app.locator('#location')).toHaveText('period=last-30#totals'); - await page.goForward(); - await expect(app.locator('#location')).toHaveText('period=last-7#totals'); - await app.getByRole('button', { name: 'Clear filter' }).click(); - await expect(page).toHaveURL(/\/bundle-host#totals$/); - await expect(app.locator('#location')).toHaveText('#totals'); - await page.goBack(); - await expect(app.locator('#location')).toHaveText('period=last-7#totals'); - const frame = page.frames().find(item => item !== page.mainFrame())!; - expect( - await frame.evaluate(() => { - try { - void parent.document; - - return false; - } catch { - return true; - } - }) - ).toBe(true); - const blockedUrl = new URL('/__test/bundle', page.url()).href; - const violation = await frame.evaluate(async url => { - const violation = new Promise<{ directive: string; blockedURI: string }>( - resolve => { - document.addEventListener( - 'securitypolicyviolation', - event => { - resolve({ - directive: event.effectiveDirective, - blockedURI: event.blockedURI, - }); - }, - { once: true } - ); - } - ); - void fetch(url).catch(() => {}); - - return violation; - }, blockedUrl); - expect(violation.directive).toBe('connect-src'); - // Browsers may strip a cross-origin URL's path from CSP reports. - expect(new URL(violation.blockedURI).origin).toBe(new URL(blockedUrl).origin); - await frame.evaluate(() => location.reload()); - await expect(page.locator('iframe')).toBeVisible(); - await expect(app.locator('#location')).toHaveText('period=last-7#totals'); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); - await page.getByRole('button', { name: 'Change javascript' }).click(); - await expect(app.locator('body')).toHaveAttribute('data-bundle-version', '2'); - await expect(page.locator('iframe')).not.toHaveAttribute( - 'data-identity', - 'original' - ); - await expect(app.locator('#location')).toHaveText('period=last-7#totals'); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); - expect(errors).toEqual([]); - expect(apiFrames).toEqual(['host', 'host']); - const diagnostic = JSON.parse( - (await page.locator('body').getAttribute('data-diagnostic'))! - ); - expect(Object.keys(diagnostic).sort()).toEqual(['direction', 'type']); -}); - -test('bundle failures recover with a fresh frame and unavailable bootstraps time out', async ({ - page, -}) => { - await page.goto('/bundle-host?broken=1'); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await page.locator('iframe').evaluate(frame => { - frame.dataset.identity = 'failed'; - }); - await page.getByRole('button', { name: 'Retry', exact: true }).click(); - await expect(page.locator('iframe')).not.toHaveAttribute( - 'data-identity', - 'failed' - ); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await page.getByRole('button', { name: 'Fix bundle' }).click(); - await expect( - page - .frameLocator('iframe') - .getByRole('button', { name: 'Query', exact: true }) - ).toBeVisible(); - await page.goto('/bundle-host?timeout=1'); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await expect(page.locator('iframe')).toBeHidden(); -}); - -test('URL bridge loads a separate-origin app and preserves navigation on reload', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - await page.goto('/bundle-host?url=1&period=last-30#totals'); - const app = page.frameLocator('iframe'); - await expect(page.locator('iframe')).toBeVisible(); - await expect(page.locator('iframe')).toHaveAttribute( - 'sandbox', - /allow-same-origin/ - ); - await expect(app.locator('#location')).toContainText('period=last-30'); - await expect(app.locator('#location')).toContainText('#totals'); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":1'); - await page.getByRole('button', { name: 'Change handler' }).click(); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await app.getByRole('button', { name: 'Last 7 days' }).click(); - await expect(page).toHaveURL(/period=last-7#daily$/); - await page.goBack(); - await expect(app.locator('#location')).toContainText('period=last-30'); - await page.reload(); - await expect(app.locator('#location')).toContainText('period=last-30'); - expect(errors).toEqual([]); -}); - -test('Embedded surfaces retain toolbar actions and live theme changes without losing navigation', async ({ - page, -}) => { - await page.goto('/bundle-host?period=last-30#totals'); - const app = page.frameLocator('iframe'); - await expect( - app.getByRole('button', { name: 'Custom toolbar action' }) - ).toBeVisible(); - await expect( - app.getByRole('heading', { name: 'Embedded report' }) - ).toHaveCount(0); - await expect( - app.getByRole('button', { name: 'Custom footer action' }) - ).toHaveCount(0); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'dark'); - - await page.getByRole('button', { name: 'Change theme' }).click(); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'light'); - await expect(app.locator('#location')).toHaveText('period=last-30#totals'); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); - - await page.getByRole('button', { name: 'Change surface' }).click(); - await expect( - app.getByRole('heading', { name: 'Embedded report' }) - ).toBeVisible(); - await expect( - app.getByRole('button', { name: 'Custom footer action' }) - ).toBeVisible(); - await expect( - app.getByRole('button', { name: 'Custom toolbar action' }) - ).toBeVisible(); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); -}); - -test('parent presentation owns the theme until standalone system preferences are restored', async ({ - page, -}) => { - await page.emulateMedia({ colorScheme: 'dark' }); - await page.goto('/bundle-host'); - const app = page.frameLocator('iframe'); - await page.getByRole('button', { name: 'Change theme' }).click(); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'light'); - - await page - .getByRole('button', { name: 'Toggle parent presentation' }) - .click(); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'dark'); - await page.emulateMedia({ colorScheme: 'light' }); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'light'); - - await page - .getByRole('button', { name: 'Toggle parent presentation' }) - .click(); - await page.emulateMedia({ colorScheme: 'dark' }); - await expect(app.locator('html')).toHaveCSS('color-scheme', 'light'); - await expect(app.locator('body')).toHaveAttribute('data-executions', '1'); -}); - -test('hidden source iframe starts eagerly even when a caller supplies lazy loading', async ({ - page, -}) => { - let releaseBootstrap!: () => void; - const bootstrapGate = new Promise(resolve => { - releaseBootstrap = resolve; - }); - await page.route('**/__test/runtime', async route => { - await bootstrapGate; - await route.continue(); - }); - const bootstrapRequest = page.waitForRequest('**/__test/runtime'); - try { - await page.goto('/bundle-host?lazy=1', { waitUntil: 'domcontentloaded' }); - await expect(page.locator('iframe')).toBeHidden(); - await expect(page.locator('iframe')).toHaveAttribute('loading', 'eager'); - await bootstrapRequest; - releaseBootstrap(); - await expect(page.locator('iframe')).toBeVisible(); - await expect(page.frameLocator('iframe').locator('body')).toHaveAttribute( - 'data-bundle-version', - '1' - ); - } finally { - releaseBootstrap(); - } -}); diff --git a/browser-tests/bridge.spec.ts b/browser-tests/bridge.spec.ts deleted file mode 100644 index 3b51175e..00000000 --- a/browser-tests/bridge.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('iframe logs reach the current React host logger without reconnecting', async ({ - page, -}) => { - await page.goto('/bridge-host?period=last-30'); - const app = page.frameLocator('iframe'); - await expect(app.locator('#location')).toContainText('period=last-30'); - await app.getByRole('button', { name: 'Write logs' }).click(); - const entries = [ - [1, 'log', 'plain'], - [1, 'info', 'completed', { rows: 3 }], - [1, 'warn', 'slow'], - [1, 'error', 'failed'], - ]; - await expect(page.locator('#logs')).toHaveText(JSON.stringify(entries)); - await page.getByRole('button', { name: 'Change handler' }).click(); - await app.getByRole('button', { name: 'Write logs' }).click(); - entries.push(...entries.map(([, ...entry]) => [2, ...entry])); - await expect(page.locator('#logs')).toHaveText(JSON.stringify(entries)); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await expect(page.locator('#diagnostics')).toContainText( - '"route":"test:echo"' - ); - const diagnostics = await page.locator('#diagnostics').textContent(); - await page.getByRole('button', { name: 'Toggle logging' }).click(); - await app.getByRole('button', { name: 'Write logs' }).click(); - await app.locator('#result').evaluate(element => { - element.textContent = ''; - }); - // A subsequent request response confirms earlier log messages were processed. - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await expect(page.locator('#logs')).toHaveText(JSON.stringify(entries)); - await expect(page.locator('#diagnostics')).toHaveText(diagnostics!); -}); - -test('React host bridge uses the latest handler and synchronizes deep links and history', async ({ - page, -}) => { - await page.goto('/bridge-host?period=last-30#totals'); - const app = page.frameLocator('iframe'); - await expect(app.locator('#location')).toContainText('period=last-30'); - await expect(app.locator('#location')).toContainText('#totals'); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":1'); - await page.getByRole('button', { name: 'Change handler' }).click(); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toHaveText( - '{"period":"last-7","version":2}' - ); - await page.getByRole('button', { name: 'Replace iframe' }).click(); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await page.getByRole('button', { name: 'Toggle bridge' }).click(); - await page.getByRole('button', { name: 'Toggle bridge' }).click(); - await app.locator('#result').evaluate(element => { - element.textContent = ''; - }); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":2'); - await app.getByRole('button', { name: 'Last 7 days' }).click(); - await expect(page).toHaveURL(/\/bridge-host\?period=last-7#daily$/); - await expect(page.locator('body')).toHaveAttribute( - 'data-navigation-requests', - '1' - ); - await page.goBack(); - await expect(app.locator('#location')).toContainText('period=last-30'); - await expect(app.locator('#location')).toContainText('#totals'); - await page.goForward(); - await expect(app.locator('#location')).toContainText('period=last-7'); - await page.reload(); - await expect(app.locator('#location')).toContainText('period=last-7'); - await expect(app.locator('#location')).toContainText('#daily'); -}); diff --git a/browser-tests/export.spec.ts b/browser-tests/export.spec.ts deleted file mode 100644 index fea2760b..00000000 --- a/browser-tests/export.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { unzipSync, strFromU8 } from 'fflate'; -import { readFile } from 'node:fs/promises'; -import { expect, test } from '@playwright/test'; - -async function contents(download: import('@playwright/test').Download) { - const path = await download.path(); - expect(path).not.toBeNull(); - return readFile(path!, 'utf8'); -} - -test('standalone toolbar downloads a UTF-8 CSV with escaped cells', async ({ - page, -}) => { - await page.goto('/gallery'); - const downloaded = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Export CSV', exact: true }).click(); - const download = await downloaded; - expect(download.suggestedFilename()).toBe('gallery.csv'); - expect(await contents(download)).toBe( - '\uFEFFName,Count\r\n"München, ""East""",0\r\n"Two\nlines",\r\n' - ); -}); - -test('embedded toolbar exports displayed results through its host during updates and failures', async ({ - page, -}) => { - let release!: () => void; - let gate = new Promise(resolve => { - release = resolve; - }); - let fail = false; - await page.route('**/api/sql', async route => { - await gate; - if (fail) - await route.fulfill({ status: 503, json: { error: 'Unavailable' } }); - else await route.continue(); - }); - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - await expect(app.getByText('Loading data')).toBeVisible(); - await expect(app.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); - release(); - gate = Promise.resolve(); - await expect( - app - .getByRole('row', { name: 'Alpha 3', exact: true }) - .filter({ visible: true }) - ).toBeVisible(); - await expect(page.locator('iframe')).toHaveAttribute( - 'sandbox', - 'allow-scripts' - ); - - async function expectExport(filename: string, csv: string) { - const downloaded = page.waitForEvent('download'); - await app.getByRole('button', { name: 'Export CSV', exact: true }).click(); - const download = await downloaded; - expect(download.suggestedFilename()).toBe(filename); - expect(await contents(download)).toBe(csv); - } - - await page - .locator('iframe') - .screenshot({ path: test.info().outputPath('export-toolbar.png') }); - await expectExport( - 'sample-counts-all-groups.csv', - '\uFEFFGroup,Sample count\r\nAlpha,3\r\nBeta,0\r\n' - ); - gate = new Promise(resolve => { - release = resolve; - }); - await app.getByRole('searchbox', { name: 'Group', exact: true }).fill('Beta'); - await expect( - app.getByText('Showing all groups while loading group Beta…') - ).toBeVisible(); - await expectExport( - 'sample-counts-all-groups.csv', - '\uFEFFGroup,Sample count\r\nAlpha,3\r\nBeta,0\r\n' - ); - release(); - gate = Promise.resolve(); - await expect( - app.getByText('Showing group Beta', { exact: true }) - ).toBeVisible(); - fail = true; - await app - .getByRole('searchbox', { name: 'Group', exact: true }) - .fill('Alpha'); - await expect( - app.getByText( - 'Couldn’t refresh. Showing group Beta while group Alpha is unavailable.' - ) - ).toBeVisible(); - await expectExport( - 'sample-counts-group-beta.csv', - '\uFEFFGroup,Sample count\r\nBeta,0\r\n' - ); - fail = false; - await app - .getByRole('searchbox', { name: 'Group', exact: true }) - .fill('missing'); - await expect( - app.getByText('No matching groups', { exact: true }) - ).toBeVisible(); - await expect(app.getByRole('button', { name: 'Export CSV' })).toBeDisabled(); -}); - -test('embedded export failures stay visible and allow retry', async ({ - page, -}) => { - await page.goto('/starter-data-app?export-error'); - const app = page.frameLocator('iframe'); - const button = app.getByRole('button', { name: 'Export CSV', exact: true }); - await button.click(); - await expect(app.getByRole('alert')).toHaveText( - 'Couldn’t export data.Try again' - ); - await page - .locator('iframe') - .screenshot({ path: test.info().outputPath('export-error-toast.png') }); - await expect(button).toBeEnabled(); - await expect( - app.locator('.altertable-data-view-toast-region[data-position="top"]') - ).toBeVisible(); - await page - .getByRole('button', { name: 'Allow exports', exact: true }) - .click(); - const downloaded = page.waitForEvent('download'); - await app.getByRole('button', { name: 'Try again', exact: true }).click(); - expect((await downloaded).suggestedFilename()).toBe( - 'sample-counts-all-groups.csv' - ); - await expect(app.getByRole('alert')).toHaveCount(0); - await expect(button).toBeEnabled(); -}); - -for (const embedded of [false, true]) - test(`multiple datasets export CSVs and a ZIP ${embedded ? 'through the iframe host' : 'standalone'}`, async ({ - page, - }) => { - await page.goto(embedded ? '/bundle-host' : '/gallery?multiple-exports'); - const app = embedded ? page.frameLocator('iframe') : page; - const button = app.getByRole('button', { name: 'Export', exact: true }); - await button.focus(); - await expect(app.getByRole('tooltip')).toHaveText('Export data…'); - await button.press('ArrowDown'); - await expect( - app.getByRole('option', { name: 'Export Counts CSV', exact: true }) - ).toBeFocused(); - let downloaded = page.waitForEvent('download'); - await app - .getByRole('option', { name: 'Export Summary CSV', exact: true }) - .click(); - let download = await downloaded; - expect(download.suggestedFilename()).toBe('Summary.csv'); - expect(await contents(download)).toBe('\uFEFFTotal\r\n0\r\n'); - await button.click(); - downloaded = page.waitForEvent('download'); - await app - .getByRole('option', { name: 'Export all ZIP', exact: true }) - .click(); - download = await downloaded; - expect(download.suggestedFilename()).toBe('gallery.zip'); - const files = unzipSync(await readFile((await download.path())!)); - expect(Object.keys(files)).toEqual(['Counts.csv', 'Summary.csv']); - expect(Array.from(files['Summary.csv']!.slice(0, 3))).toEqual([ - 239, 187, 191, - ]); - expect(strFromU8(files['Summary.csv']!)).toBe('Total\r\n0\r\n'); - await button.click(); - downloaded = page.waitForEvent('download'); - await app - .getByRole('option', { name: 'Export all ZIP', exact: true }) - .click(); - expect((await downloaded).suggestedFilename()).toBe('gallery.zip'); - await button.click(); - await page.screenshot({ path: test.info().outputPath('export-menu.png') }); - await app.getByRole('listbox').press('Escape'); - await expect(app.getByRole('listbox')).toHaveCount(0); - await expect(button).toBeFocused(); - }); diff --git a/browser-tests/gallery.spec.ts b/browser-tests/gallery.spec.ts deleted file mode 100644 index c142480f..00000000 --- a/browser-tests/gallery.spec.ts +++ /dev/null @@ -1,1115 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('gallery preserves control defaults, status, and keyboard selection', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - await expect( - page.getByRole('heading', { name: 'Data app gallery' }) - ).toBeVisible(); - await expect( - page.getByRole('button', { name: 'Disabled action', exact: true }) - ).toBeDisabled(); - await expect( - page.getByRole('button', { name: 'Disabled categories: HTTP', exact: true }) - ).toBeDisabled(); - const checkbox = page.getByRole('checkbox', { - name: 'Include archived records', - exact: true, - }); - await checkbox.check(); - await expect(checkbox).toBeChecked(); - await checkbox.press('Tab'); - await page.keyboard.press('Shift+Tab'); - await expect( - page.locator('#controls .altertable-checkbox').first() - ).toHaveCSS('outline-width', '1px'); - await page - .getByRole('button', { - name: 'Loading categories: Choose categories', - exact: true, - }) - .click(); - let dialog = page.getByRole('dialog', { name: 'Loading categories options' }); - await expect(dialog.locator('.altertable-combobox-skeletons')).toBeVisible(); - await expect( - dialog.locator('.altertable-search-input-spinner') - ).toBeVisible(); - await expect(dialog.getByRole('option')).toHaveCount(0); - await dialog.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page - .getByRole('button', { name: 'Complete loading', exact: true }) - .click(); - await page - .getByRole('button', { - name: 'Loading categories: Choose categories', - exact: true, - }) - .click(); - dialog = page.getByRole('dialog', { name: 'Loading categories options' }); - await expect(dialog.getByRole('option')).toHaveCount(3); - await dialog.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page - .getByRole('button', { name: 'Failed categories: HTTP', exact: true }) - .click(); - dialog = page.getByRole('dialog', { name: 'Failed categories options' }); - await expect(dialog.locator('.altertable-request-hint-message')).toHaveText( - 'Couldn’t load values' - ); - await expect( - dialog.getByRole('option', { name: 'Postgres', exact: true }) - ).toBeEnabled(); - await dialog.getByRole('button', { name: 'Try again' }).click(); - await expect(dialog.locator('.altertable-request-hint-message')).toHaveCount( - 0 - ); - await dialog.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page - .getByRole('button', { name: 'Categories: Choose categories', exact: true }) - .click(); - dialog = page.getByRole('dialog', { name: 'Categories options' }); - const search = dialog.getByRole('searchbox'); - const missing = dialog.getByRole('option', { - name: 'No value Records without a category value', - }); - await expect - .poll(() => - missing.evaluate( - element => getComputedStyle(element, '::before').borderTopWidth - ) - ) - .toBe('1px'); - await expect(missing).toHaveCSS( - 'border-radius', - await dialog - .getByRole('option', { name: 'HTTP', exact: true }) - .evaluate(element => getComputedStyle(element).borderRadius) - ); - await page.screenshot({ - path: `/tmp/runtime-gallery-divider-${test.info().project.name}.png`, - }); - await search.fill('No value'); - await expect(dialog.getByRole('option')).toHaveCount(1); - await expect - .poll(() => - missing.evaluate( - element => getComputedStyle(element, '::before').borderTopWidth - ) - ) - .toBe('0px'); - await search.fill(''); - await search.focus(); - await expect(dialog.locator('.altertable-search-input-wrap')).toHaveCSS( - 'outline-style', - 'none' - ); - await search.press('ArrowDown'); - await expect( - dialog.getByRole('option', { name: 'HTTP', exact: true }) - ).toBeFocused(); - await expect( - dialog.getByRole('option', { name: 'HTTP', exact: true }) - ).toHaveCSS('outline-width', '1px'); - await page.keyboard.press('Space'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Space'); - await expect( - dialog.getByRole('option', { name: 'Other', exact: true }) - ).toBeDisabled(); - await dialog.getByRole('button', { name: 'Clear selection' }).click(); - await expect(dialog).toHaveCount(0); - await page - .getByRole('button', { name: 'Categories: Choose categories', exact: true }) - .click(); - await expect( - dialog.getByRole('option', { name: 'Other', exact: true }) - ).toBeEnabled(); - await search.fill('does not exist'); - await expect( - dialog.getByText('No matching values', { exact: true }).last() - ).toBeVisible(); -}); - -test('gallery widget and inspection show chart tooltips and share view changes', async ({ - page, - isMobile, -}) => { - await page.goto('/gallery'); - const widget = page.locator('.altertable-data-widget').filter({ - has: page.getByRole('heading', { name: 'Weekly activity', exact: true }), - }); - const trigger = widget.getByRole('button', { - name: 'Explore Weekly activity', - }); - if (isMobile) await trigger.tap(); - else await trigger.click(); - const sheet = page.getByRole('dialog', { - name: 'Weekly activity', - exact: true, - }); - const sheetPoint = sheet.getByRole('button', { - name: 'Wednesday: 8 events', - exact: true, - }); - if (isMobile) await sheetPoint.tap(); - else await sheetPoint.hover(); - await expect(sheet.getByRole('tooltip')).toContainText('8 events'); - await page.keyboard.press('Escape'); - await expect(sheet.getByRole('tooltip')).toHaveCount(0); - await expect(sheet).toBeVisible(); - await sheet.getByRole('tab', { name: 'Summary', exact: true }).click(); - await expect( - sheet.getByText( - '20 events this week, concentrated on Monday and Wednesday. Tuesday had no recorded activity.', - { exact: true } - ) - ).toBeVisible(); - await sheet.getByRole('button', { name: 'Close panel', exact: true }).click(); - await expect(sheet).not.toBeVisible(); - await expect(trigger).toBeFocused(); - const body = widget.locator(':scope > .altertable-data-widget-body'); - await expect( - body.getByRole('tab', { name: 'Summary', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await body.getByRole('tab', { name: 'Chart', exact: true }).click(); - const bodyPoint = body.getByRole('button', { - name: 'Wednesday: 8 events', - exact: true, - }); - if (isMobile) await bodyPoint.tap(); - else await bodyPoint.hover(); - await expect(page.getByRole('tooltip')).toContainText('8 events'); -}); - -test('gallery uses shared defaults in both themes and narrow containers', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - const action = page.getByRole('button', { name: 'Hint action', exact: true }); - const backgrounds: string[] = []; - for (const theme of ['light', 'dark'] as const) { - if (theme === 'dark') { - await page.getByRole('button', { name: 'Switch to dark theme' }).click(); - await expect - .poll(() => - action.evaluate(element => getComputedStyle(element).backgroundColor) - ) - .not.toBe(backgrounds[0]); - } - const surfaceColor = await page - .locator('#widgets .altertable-data-widget') - .first() - .evaluate(element => getComputedStyle(element).backgroundColor); - await expect(action).toHaveCSS('background-color', surfaceColor); - backgrounds.push(surfaceColor); - await action.press('Tab'); - await action.focus(); - const tooltip = page.getByRole('tooltip'); - await expect(tooltip).toBeVisible(); - await expect(action).toHaveAttribute( - 'aria-describedby', - (await tooltip.getAttribute('id')) ?? '' - ); - await expect(action).toHaveCSS('outline-width', '1px'); - await action.press('Escape'); - await expect(tooltip).not.toBeVisible(); - await page.getByRole('tab', { name: 'App layout', exact: true }).click(); - const narrow = page.getByTestId('narrow-controls'); - const bounds = await narrow.evaluate(element => ({ - width: element.clientWidth, - scrollWidth: element.scrollWidth, - })); - expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.width + 1); - await page.screenshot({ - path: `/tmp/runtime-gallery-${test.info().project.name}-${theme}.png`, - fullPage: true, - }); - await page - .getByRole('tab', { name: 'Filters & actions', exact: true }) - .click(); - } - expect(backgrounds[0]).not.toBe(backgrounds[1]); -}); - -test('picker refresh and failure keep cached choices and retry in fixed slots', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - await page - .getByRole('button', { name: 'Start picker cycle', exact: true }) - .click(); - await page - .getByRole('button', { name: 'Cycling categories: HTTP', exact: true }) - .click(); - const dialog = page.getByRole('dialog', { - name: 'Cycling categories options', - }); - const option = dialog.getByRole('option', { name: 'HTTP', exact: true }); - const input = dialog.getByRole('searchbox'); - // The panel can reposition at viewport edges; cached choices stay fixed inside it. - - async function optionOffset() { - return (await option.boundingBox())!.y - (await dialog.boundingBox())!.y; - } - const initial = await optionOffset(); - await expect( - dialog.locator('.altertable-search-input-spinner') - ).toBeVisible(); - await expect(input).toBeFocused(); - expect(Math.abs((await optionOffset()) - initial)).toBeLessThanOrEqual(1); - const hint = dialog.locator('.altertable-request-hint'); - await expect(hint).toHaveAttribute('data-state', 'error'); - await expect(option).toBeEnabled(); - expect(Math.abs((await optionOffset()) - initial)).toBeLessThanOrEqual(1); - const message = await hint - .locator('.altertable-request-hint-message') - .boundingBox(); - const retry = await hint - .getByRole('button', { name: 'Try again' }) - .boundingBox(); - expect(retry!.y).toBeGreaterThan(message!.y + message!.height); - const hintBox = await hint.boundingBox(); - expect( - Math.abs(retry!.x + retry!.width / 2 - (hintBox!.x + hintBox!.width / 2)) - ).toBeLessThanOrEqual(1); - await hint.getByRole('button', { name: 'Try again' }).click(); - await expect(hint).toHaveCount(0); - expect(Math.abs((await optionOffset()) - initial)).toBeLessThanOrEqual(1); -}); - -test('widget refresh slots preserve data, geometry and inspection feedback', async ({ - page, -}) => { - await page.goto('/gallery?view=states'); - const widget = page.locator('.altertable-data-widget').filter({ - has: page.getByRole('heading', { name: 'Stable activity', exact: true }), - }); - const body = widget.locator(':scope > .altertable-data-widget-body'); - const initial = await body.evaluate( - element => element.getBoundingClientRect().top + window.scrollY - ); - const bounds = await widget.boundingBox(); - for (const button of [ - 'Widget updating', - 'Widget failed', - 'Long widget failure', - 'Widget ready', - ]) { - await page.getByRole('button', { name: button, exact: true }).click(); - expect( - Math.abs( - (await body.evaluate( - element => element.getBoundingClientRect().top + window.scrollY - )) - initial - ) - ).toBeLessThanOrEqual(1); - expect( - Math.abs((await widget.boundingBox())!.height - bounds!.height) - ).toBeLessThanOrEqual(1); - await expect(body).toContainText('42 events remain visible.'); - } - await page - .getByRole('button', { name: 'Widget failed', exact: true }) - .click(); - await widget.getByRole('button', { name: 'Explore Stable activity' }).click(); - const sheet = page.getByRole('dialog', { - name: 'Stable activity', - exact: true, - }); - await expect(sheet.getByRole('alert')).toContainText('Couldn’t refresh'); - await sheet.getByRole('button', { name: 'Retry', exact: true }).click(); - await expect(sheet.locator('.altertable-widget-status')).toHaveAttribute( - 'data-state', - 'idle' - ); -}); - -test('gallery spans all UI families and handles empty, overflow and request recovery', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - await page.goto('/gallery?view=filters'); - expect(await page.locator('.gallery-case').count()).toBeGreaterThanOrEqual( - 65 - ); - await page - .getByRole('button', { name: 'Many categories: Category 100', exact: true }) - .click(); - const picker = page.getByRole('dialog', { name: 'Many categories options' }); - await expect(picker.getByRole('option')).toHaveCount(100); - const scroll = picker.locator('.altertable-combobox-options'); - expect( - await scroll.evaluate( - element => element.scrollHeight > element.clientHeight - ) - ).toBe(true); - await picker.getByRole('searchbox').fill('nonexistent'); - await expect(picker.locator('.altertable-combobox-empty')).toHaveText( - 'No matching values' - ); - await picker.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page.getByRole('tab', { name: 'Request states', exact: true }).click(); - const request = page.getByRole('region', { - name: 'Fixture request', - exact: true, - }); - await page.getByRole('button', { name: 'loading', exact: true }).click(); - await expect(request.locator('.altertable-content-skeleton')).toBeVisible(); - await page.getByRole('button', { name: 'error', exact: true }).click(); - await request.getByRole('button', { name: 'Retry', exact: true }).click(); - await expect(request).toContainText('42 recorded events'); - await page.getByRole('button', { name: 'empty', exact: true }).click(); - await expect(request.getByText('No records', { exact: true })).toBeVisible(); - const dimensions = await page.evaluate(() => ({ - width: document.documentElement.clientWidth, - scroll: document.documentElement.scrollWidth, - })); - expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width + 1); - expect(errors).toEqual([]); -}); - -test('gallery respects reduced motion and retry without cached data', async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/gallery?view=filters'); - await page - .getByRole('button', { name: 'Refreshing categories: HTTP', exact: true }) - .click(); - let dialog = page.getByRole('dialog', { - name: 'Refreshing categories options', - }); - await expect(dialog.locator('.altertable-search-input-spinner')).toHaveCSS( - 'animation-name', - 'none' - ); - await dialog.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page - .getByRole('button', { - name: 'No cached categories: Select categories', - exact: true, - }) - .click(); - dialog = page.getByRole('dialog', { name: 'No cached categories options' }); - await expect(dialog.getByRole('alert')).toContainText('Couldn’t load values'); - await dialog.getByRole('button', { name: 'Try again' }).click(); - await expect(dialog.locator('.altertable-combobox-skeletons')).toBeVisible(); - await expect(dialog.getByRole('button', { name: 'Try again' })).toHaveCount( - 0 - ); - await expect(dialog.getByRole('option')).toHaveCount(3); - await dialog.getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await page.getByRole('tab', { name: 'Request states', exact: true }).click(); - const title = page.getByText('Nothing to show yet', { exact: true }); - const description = page.getByText( - 'A description has quieter typography than its title.', - { - exact: true, - } - ); - expect( - await title.evaluate(element => getComputedStyle(element).fontWeight) - ).toBe('600'); - expect( - await title.evaluate(element => - parseFloat(getComputedStyle(element).fontSize) - ) - ).toBeGreaterThan( - await description.evaluate(element => - parseFloat(getComputedStyle(element).fontSize) - ) - ); -}); - -test('clear closes the picker and idle panels have no feedback gap', async ({ - page, -}) => { - await page.goto('/gallery?view=filters'); - await page - .getByRole('button', { name: 'Resettable category: HTTP', exact: true }) - .click(); - const dialog = page.getByRole('dialog', { - name: 'Resettable category options', - }); - await expect(dialog.locator('.altertable-request-hint')).toHaveCount(0); - await dialog.getByRole('option', { name: 'Postgres', exact: true }).click(); - await page - .getByRole('button', { name: 'Resettable category: Postgres', exact: true }) - .click(); - await dialog - .getByRole('button', { name: 'Clear selection', exact: true }) - .click(); - await expect(dialog).toHaveCount(0); - await expect( - page.getByRole('button', { name: 'Resettable category: HTTP', exact: true }) - ).toBeFocused(); - await expect( - page.getByRole('button', { name: 'Compact ghost action', exact: true }) - ).toHaveAttribute('data-variant', 'ghost'); - await expect( - page.getByRole('button', { name: 'Compact ghost action', exact: true }) - ).toHaveAttribute('data-size', 'compact'); -}); - -test('widget toolbar owns shimmer, retry tooltip and stable action positions', async ({ - page, -}) => { - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/gallery?view=states'); - const widget = page.locator('.altertable-data-widget').filter({ - has: page.getByRole('heading', { name: 'Stable activity', exact: true }), - }); - const toolbar = widget.locator('.altertable-data-widget-help'); - const status = toolbar.locator(':scope > .altertable-widget-status'); - const inspect = widget.getByRole('button', { - name: 'Explore Stable activity', - }); - const initial = await inspect.boundingBox(); - await page - .getByRole('button', { name: 'Widget updating', exact: true }) - .click(); - await expect(toolbar.getByRole('status')).toHaveText('Refreshing'); - await expect(toolbar).toHaveCSS('opacity', '1'); - await expect(status.locator('svg')).toHaveCount(0); - await expect(status.locator('.altertable-widget-status-shimmer')).toHaveCSS( - 'animation-name', - 'none' - ); - await page - .getByRole('button', { name: 'Widget failed', exact: true }) - .click(); - expect( - Math.abs((await inspect.boundingBox())!.x - initial!.x) - ).toBeLessThanOrEqual(1); - const retry = status.getByRole('button', { name: 'Retry', exact: true }); - await inspect.focus(); - await inspect.press('Tab'); - await expect(retry).toBeFocused(); - await expect(page.getByRole('tooltip')).toContainText( - 'Try again to refresh these results.' - ); - await page.screenshot({ - path: `/tmp/runtime-toolbar-retry-${test.info().project.name}.png`, - }); - await retry.click(); - await expect(status).toHaveAttribute('data-state', 'idle'); -}); - -test('standalone viewers retain their chosen theme after reload', async ({ - page, -}) => { - await page.goto('/gallery'); - await page.getByRole('button', { name: 'Switch to dark theme' }).click(); - await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark'); - await page.reload(); - await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark'); - await expect( - page.getByRole('button', { name: 'Switch to light theme' }) - ).toBeVisible(); -}); - -test('widgets keep inspection on headings and suppress unneeded empty chrome', async ({ - page, -}) => { - await page.goto('/gallery?view=states'); - const widget = page.locator('.altertable-data-widget').filter({ - has: page.getByRole('heading', { name: 'Stable activity', exact: true }), - }); - const heading = widget.getByRole('heading', { - name: 'Stable activity', - exact: true, - }); - await expect( - heading.getByRole('button', { name: 'Explore Stable activity' }) - ).toBeVisible(); - await expect( - widget.locator( - '.altertable-data-widget-help .altertable-widget-heading-trigger' - ) - ).toHaveCount(0); - const table = page.locator('.altertable-data-widget').filter({ - has: page.getByRole('heading', { name: 'Stable table', exact: true }), - }); - await expect( - table.getByRole('navigation', { name: 'Table pages' }) - ).toHaveCount(0); - const emptyChart = page.locator('.gallery-case').filter({ - has: page.getByRole('heading', { name: 'Empty chart', exact: true }), - }); - await expect( - emptyChart.locator('.altertable-selectable-bars-help') - ).toHaveCount(0); - const error = page - .locator('.altertable-status-panel[data-status="error"]') - .first(); - await expect(error).toHaveCSS('border-top-width', '0px'); - await page - .getByRole('tab', { name: 'Filters & actions', exact: true }) - .click(); - await page - .getByRole('button', { name: 'Resettable category: HTTP', exact: true }) - .click(); - const search = page - .getByRole('dialog', { name: 'Resettable category options' }) - .locator('.altertable-search-input-wrap'); - await expect(search).toHaveCSS('border-top-width', '0px'); - await expect(search).toHaveCSS('border-bottom-width', '1px'); - const divider = await search.evaluate( - element => getComputedStyle(element).borderBottomColor - ); - await expect(search.locator(':scope > svg')).toHaveCSS('opacity', '1'); - await search.getByRole('searchbox').press('ArrowDown'); - await expect(search).toHaveCSS('border-bottom-color', divider); - await expect(search.locator(':scope > svg')).toHaveCSS('opacity', '0.65'); - await expect(page.locator('.altertable-combobox-popover')).toHaveCSS( - 'overflow', - 'hidden' - ); -}); - -test('menu search aligns labels and Escape blurs empty searches', async ({ - page, -}) => { - await page.goto('/gallery'); - const tableSearch = page.getByRole('searchbox', { - name: 'Search table records', - }); - await tableSearch.focus(); - await tableSearch.press('Escape'); - await expect(tableSearch).not.toBeFocused(); - await page - .getByRole('tab', { name: 'Filters & actions', exact: true }) - .click(); - await page - .getByRole('button', { name: 'Resettable category: HTTP', exact: true }) - .click(); - const dialog = page.getByRole('dialog', { - name: 'Resettable category options', - }); - const search = dialog.getByRole('searchbox'); - await search.fill('HTTP'); - const inputBox = await search.boundingBox(); - const labelBox = await dialog - .locator('.altertable-combobox-option-content') - .boundingBox(); - expect(Math.abs(inputBox!.x - labelBox!.x)).toBeLessThanOrEqual(1); - await search.fill(''); - await search.press('Escape'); - await expect(search).not.toBeFocused(); - await expect(dialog).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(dialog).toHaveCount(0); -}); - -test('gallery categories support keyboard navigation, links, and persistent demo state', async ({ - page, -}) => { - await page.goto('/gallery'); - const tabs = page.getByRole('tablist', { name: 'Gallery categories' }); - await expect(tabs.getByRole('tab')).toHaveCount(8); - await expect( - page.locator( - '[data-testid="gallery-tabs"] > .react-aria-TabPanels > .react-aria-TabPanel:not([data-inert])' - ) - ).toHaveCount(1); - await expect( - page.getByRole('tabpanel', { name: 'Overview', exact: true }) - ).toBeVisible(); - await expect( - page.getByRole('heading', { - name: 'Monday accounts for 60% of recorded activity', - }) - ).toBeVisible(); - await tabs.getByRole('tab', { name: 'Overview', exact: true }).focus(); - await page.keyboard.press('ArrowRight'); - await expect( - tabs.getByRole('tab', { name: 'Widgets', exact: true }) - ).toBeFocused(); - await expect(page).toHaveURL(/view=widgets/); - await page.keyboard.press('ArrowRight'); - await expect( - tabs.getByRole('tab', { name: 'Filters & actions', exact: true }) - ).toBeFocused(); - await expect(page).toHaveURL(/view=filters/); - const checkbox = page.getByRole('checkbox', { - name: 'Include archived records', - exact: true, - }); - await checkbox.check(); - await tabs.getByRole('tab', { name: 'Metrics', exact: true }).click(); - await expect(checkbox).not.toBeVisible(); - await tabs - .getByRole('tab', { name: 'Filters & actions', exact: true }) - .click(); - await expect(checkbox).toBeChecked(); - await page.goBack(); - await expect( - tabs.getByRole('tab', { name: 'Metrics', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await page.reload(); - await expect( - tabs.getByRole('tab', { name: 'Metrics', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await page.evaluate(() => { - window.location.hash = 'dates'; - }); - await expect( - tabs.getByRole('tab', { name: 'Filters & actions', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await expect( - page.getByRole('heading', { - name: 'Dates, periods and freshness', - exact: true, - }) - ).toBeVisible(); - await page.goBack(); - await expect(page).toHaveURL(/view=metrics$/); - await expect( - tabs.getByRole('tab', { name: 'Metrics', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await page.goForward(); - await expect(page).toHaveURL(/view=filters#dates$/); - await expect( - tabs.getByRole('tab', { name: 'Filters & actions', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await tabs.getByRole('tab', { name: 'Metrics', exact: true }).click(); - await page.reload(); - await expect( - tabs.getByRole('tab', { name: 'Metrics', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); - await page.goto('/gallery?view=unknown'); - await expect( - tabs.getByRole('tab', { name: 'Overview', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); -}); - -test('every gallery category renders in both themes without page overflow', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - await page.goto('/gallery'); - const tabs = page.getByRole('tablist', { name: 'Gallery categories' }); - for (const theme of ['light', 'dark']) { - if (theme === 'dark') - await page.getByRole('button', { name: 'Switch to dark theme' }).click(); - for (const label of [ - 'Overview', - 'Widgets', - 'Filters & actions', - 'Metrics', - 'Tables & charts', - 'Request states', - 'Stories & evidence', - 'App layout', - ]) { - await tabs.getByRole('tab', { name: label, exact: true }).click(); - const panel = page.getByRole('tabpanel', { name: label, exact: true }); - await expect(panel).toBeVisible(); - await expect( - panel.getByRole('heading', { level: 2 }).first() - ).toBeVisible(); - const dimensions = await page.evaluate(() => ({ - width: document.documentElement.clientWidth, - scroll: document.documentElement.scrollWidth, - })); - expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width + 1); - const main = await page.locator('.altertable-app-main').boundingBox(); - const footer = await page - .locator( - '.altertable-app-layout > .altertable-app-footer .altertable-app-footer-inner' - ) - .boundingBox(); - expect(Math.abs(main!.x - footer!.x)).toBeLessThanOrEqual(1); - expect(Math.abs(main!.width - footer!.width)).toBeLessThanOrEqual(1); - await page.evaluate(() => window.scrollTo(0, 0)); - await page.screenshot({ - path: `/tmp/gallery-${test.info().project.name}-${theme}-${label.toLowerCase().replaceAll(/[^a-z]+/g, '-')}.png`, - }); - } - } - expect(errors).toEqual([]); -}); - -test('text widgets share the frame and retain narrative scope through region changes', async ({ - page, -}) => { - await page.goto('/gallery#text'); - const section = page.getByRole('region', { - name: 'Text widgets', - exact: true, - }); - const narrative = section - .locator('.altertable-text-widget') - .filter({ hasText: 'Activity for this selection' }); - await expect(narrative).toContainText('42 people were active in Europe.'); - await expect(narrative).toHaveCSS('border-top-width', '1px'); - await section - .getByRole('button', { name: 'Narrative region: Europe' }) - .click(); - await page.getByRole('option', { name: 'Asia', exact: true }).click(); - await expect(narrative).toContainText('42 people were active in Europe.'); - await section.getByRole('button', { name: 'Fail text refresh' }).click(); - await expect(section.getByRole('alert')).toContainText('Europe'); - await expect(narrative).toContainText('42 people were active in Europe.'); - await section.getByRole('button', { name: 'Resolve text request' }).click(); - await expect(narrative).toContainText('18 people were active in Asia.'); - await section.getByRole('button', { name: 'Show zero activity' }).click(); - await expect(narrative).toContainText('No activity was recorded in Asia.'); - await section.getByRole('button', { name: 'Initial text loading' }).click(); - await expect(narrative).toHaveAttribute('aria-busy', 'true'); - await expect( - narrative.locator('.altertable-text-widget-skeleton') - ).toBeVisible(); - await expect(narrative).toHaveCSS('border-top-width', '1px'); - await expect(narrative).not.toContainText('No activity was recorded'); -}); - -test('widget insets align across metrics, visualizations, and loading states', async ({ - page, -}) => { - await page.goto('/gallery'); - const metric = page.locator('.altertable-metric-widget').first(); - const header = page.locator('.altertable-data-widget-header').first(); - const body = page - .locator('.altertable-data-widget-body[data-padding="inset"]') - .first(); - const padding = await metric - .locator('.altertable-data-widget-header') - .evaluate(element => getComputedStyle(element).paddingLeft); - for (const frame of [header, body]) { - await expect(frame).toHaveCSS('padding-left', padding); - await expect(frame).toHaveCSS('padding-right', padding); - } - const valueInset = await metric.evaluate(element => { - const value = element.querySelector( - ':scope > .altertable-data-widget-body .altertable-metric-value' - )!; - return ( - value.getBoundingClientRect().left - element.getBoundingClientRect().left - ); - }); - expect(valueInset).toBeCloseTo(parseFloat(padding) + 1, 0); - await expect(header).toHaveCSS('padding-top', padding); - await metric - .getByRole('button', { name: 'Explore Recorded events', exact: true }) - .click(); - const inspection = page.getByRole('dialog'); - await expect(inspection.getByText('20', { exact: true })).toBeVisible(); - await expect( - inspection.getByText('12 on Monday · 8 on Wednesday', { exact: true }) - ).toHaveCount(0); - await page.keyboard.press('Escape'); - - await page.getByRole('tab', { name: 'Request states', exact: true }).click(); - const panel = page.getByRole('tabpanel', { name: 'Request states' }); - for (const selector of [ - '.altertable-metric-widget.altertable-content-skeleton', - '.altertable-data-widget.altertable-content-skeleton', - ]) { - const skeleton = panel.locator(selector).first(); - await expect(skeleton).toHaveCSS('padding-left', padding); - await expect(skeleton).toHaveCSS('padding-right', padding); - } - await page.getByRole('tab', { name: 'Metrics', exact: true }).click(); - const loadingWidget = page.getByRole('region', { - name: 'Loading metric', - exact: true, - }); - await expect(loadingWidget).toHaveAttribute('aria-busy', 'true'); - await expect( - loadingWidget.locator('.altertable-metric-loading-value') - ).toBeVisible(); - for (const part of [ - '.altertable-data-widget-header', - '.altertable-data-widget-body', - ]) { - await expect(loadingWidget.locator(part)).toHaveCSS( - 'padding-left', - padding - ); - await expect(loadingWidget.locator(part)).toHaveCSS( - 'padding-right', - padding - ); - } -}); - -test('prose links have pointer and keyboard affordances', async ({ page }) => { - await page.goto('/gallery#text'); - const link = page.getByRole('link', { - name: 'Explore the dashboard composition', - exact: true, - }); - await expect(link).toHaveCSS('text-decoration-thickness', '1px'); - await link.hover(); - await expect(link).toHaveCSS('text-decoration-thickness', '2px'); - await page.mouse.move(0, 0); - await link.focus(); - await expect(link).toHaveCSS('outline-width', '1px'); - await expect(link).toHaveCSS('outline-style', 'solid'); -}); - -test('Widgets tab shows composable data displays in the shared frame', async ({ - page, -}) => { - await page.goto('/gallery'); - await page.getByRole('tab', { name: 'Widgets', exact: true }).click(); - await expect(page).toHaveURL(/\/gallery\?view=widgets$/); - await expect( - page.getByRole('tablist', { name: 'Gallery categories' }) - ).toHaveCount(1); - const panel = page.getByRole('tabpanel', { name: 'Widgets', exact: true }); - await expect( - panel.getByRole('region', { name: /basic example$/ }) - ).toHaveCount(9); - for (const name of [ - 'Comparison', - 'Ranking', - 'Breakdown', - 'BarChart', - 'LineChart', - 'AreaChart', - 'PieChart', - 'ScatterChart', - 'DataTable', - ]) { - await expect( - panel.getByRole('heading', { name, exact: true }) - ).toBeVisible(); - } - await expect( - panel.getByText(/^(Latest|Selected|Preview|Largest)$/) - ).toHaveCount(0); - await expect(panel.getByText(/Use arrow keys to move/)).toHaveCount(0); - await expect( - panel.getByRole('button', { name: 'Clear selection' }) - ).toHaveCount(0); - await expect( - panel - .getByRole('region', { name: 'Charts', exact: true }) - .getByRole('region', { name: /basic example$/ }) - ).toHaveCount(5); - await expect( - panel - .getByRole('region', { name: 'Custom visualizations', exact: true }) - .getByRole('region', { name: /basic example$/ }) - ).toHaveCount(4); - await expect(panel.getByRole('table')).toHaveCount(1); - await expect(panel.getByRole('rowheader', { name: 'Monday' })).toBeVisible(); - await page.reload(); - await expect(panel).toBeVisible(); - await page.getByRole('button', { name: 'Switch to dark theme' }).click(); - const dimensions = await page.evaluate(() => ({ - width: document.documentElement.clientWidth, - scroll: document.documentElement.scrollWidth, - })); - expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width + 1); - await page.getByRole('tab', { name: 'Overview', exact: true }).click(); - await expect(page).toHaveURL(/\/gallery\?view=overview$/); - await expect( - page.getByRole('tablist', { name: 'Gallery categories' }) - ).toBeVisible(); -}); - -for (const [name, first, second, value] of [ - ['BarChart', 'Web: 84 orders', 'App: 112 orders', '84 orders'], - ['LineChart', 'Mon: 180 ms', 'Tue: 165 ms', '180 ms'], - ['AreaChart', 'Jan: 120 GB', 'Feb: 155 GB', '120 GB'], - [ - 'PieChart', - 'Direct: 4,000 visits, 40%', - 'Organic search: 3,200 visits, 32%', - '4,000 visits', - ], - [ - 'ScatterChart', - 'Atlas: Request volume 120 requests, Response time 145 ms', - 'Birch: Request volume 240 requests, Response time 160 ms', - 'Response time: 145 ms', - ], -]) { - test(`${name} exposes pointer tooltips without keyboard inspection`, async ({ - page, - isMobile, - }) => { - await page.goto('/gallery?view=widgets'); - const chart = page.getByRole('region', { - name: `${name} basic example`, - exact: true, - }); - const point = chart.getByRole('button', { name: first, exact: true }); - const next = chart.getByRole('button', { name: second, exact: true }); - if (isMobile) { - await point.tap(); - await expect(page.getByRole('tooltip')).toContainText(value); - await point.tap(); - await expect(page.getByRole('tooltip')).toHaveCount(0); - await point.tap(); - await chart.getByRole('heading', { name, exact: true }).tap(); - await expect(page.getByRole('tooltip')).toHaveCount(0); - } else { - await point.hover(); - await expect(page.getByRole('tooltip')).toContainText(value); - await point.click(); - await chart.getByRole('heading', { name, exact: true }).hover(); - await expect(page.getByRole('tooltip')).toHaveCount(0); - } - await expect(point).toHaveAttribute('tabindex', '-1'); - await expect(next).toHaveAttribute('tabindex', '-1'); - await page.mouse.move(0, 0); - await page.keyboard.press('Tab'); - await point.focus(); - await expect( - page.locator('[role=tooltip][data-variant=chart]') - ).toHaveCount(0); - if (isMobile) await next.tap(); - else await next.hover(); - await expect(page.getByRole('tooltip')).toHaveCount(1); - await page.evaluate(() => window.scrollBy(0, 150)); - await expect(page.getByRole('tooltip')).toHaveCount(0); - }); -} - -test('pie slice tooltips emphasize only the inspected category', async ({ - page, - isMobile, -}) => { - await page.goto('/gallery?view=widgets'); - const pie = page.getByRole('region', { - name: 'PieChart basic example', - exact: true, - }); - const slices = pie.locator('.altertable-pie-slice'); - if (isMobile) await slices.first().tap({ position: { x: 70, y: 70 } }); - else await slices.first().hover({ position: { x: 70, y: 70 } }); - await expect(page.getByRole('tooltip')).toContainText('Direct'); - await expect(slices.first()).toHaveCSS('opacity', '1'); - await expect(slices.nth(1)).toHaveCSS('opacity', '0.3'); - if (isMobile) { - await pie.locator('svg').tap({ position: { x: 110, y: 190 } }); - await expect(page.getByRole('tooltip')).toContainText('Organic search'); - await expect(slices.nth(1)).toHaveCSS('opacity', '1'); - } - await page.keyboard.press('Escape'); - await expect(page.getByRole('tooltip')).toHaveCount(0); - await expect(slices.nth(1)).toHaveCSS('opacity', '1'); -}); - -test('former component gallery links open the Widgets tab', async ({ - page, -}) => { - await page.goto('/gallery/components'); - await expect(page).toHaveURL(/\/gallery\?view=widgets$/); - await expect( - page.getByRole('tab', { name: 'Widgets', exact: true }) - ).toHaveAttribute('aria-selected', 'true'); -}); - -for (const width of [375, 600, 960, 1280]) { - test(`Widgets uses responsive app layout at ${width}px`, async ({ - page, - isMobile, - }) => { - test.skip(isMobile, 'Explicit widths cover the responsive layout.'); - await page.setViewportSize({ width, height: 900 }); - await page.goto('/gallery?view=widgets'); - const panel = page.getByRole('tabpanel', { name: 'Widgets', exact: true }); - const geometry = await panel.evaluate(element => { - const cards = [ - ...element.querySelectorAll('[aria-label$="basic example"]'), - ]; - const rects = cards.map(card => { - const { x, y, width, height, right } = card.getBoundingClientRect(); - return { x, y, width, height, right }; - }); - const chartGrid = cards[0]! - .closest('.altertable-grid')! - .getBoundingClientRect(); - const line = element.querySelector('.altertable-trend-scroll')!; - return { - rects, - chartWidth: chartGrid.width, - pageWidth: document.documentElement.clientWidth, - scrollWidth: document.documentElement.scrollWidth, - lineWidth: line.clientWidth, - lineScroll: line.scrollWidth, - }; - }); - const [bar, line, , , scatter] = geometry.rects; - expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.pageWidth + 1); - for (const card of geometry.rects) { - expect(card.x).toBeGreaterThanOrEqual(0); - expect(card.right).toBeLessThanOrEqual(geometry.pageWidth); - } - if (width >= 960) { - expect(Math.abs(bar!.y - line!.y)).toBeLessThan(1); - expect(line!.x).toBeGreaterThan(bar!.right); - expect(geometry.lineScroll).toBeLessThanOrEqual(geometry.lineWidth + 1); - } else { - expect(line!.y).toBeGreaterThanOrEqual(bar!.y + bar!.height); - expect(Math.abs(bar!.x - line!.x)).toBeLessThan(1); - } - expect(Math.abs(scatter!.width - geometry.chartWidth)).toBeLessThan(1); - }); -} - -test('chart tooltips dismiss when their chart scrolls horizontally', async ({ - page, - isMobile, -}) => { - await page.setViewportSize({ width: 320, height: 900 }); - await page.goto('/gallery?view=widgets'); - const chart = page.getByRole('region', { - name: 'LineChart basic example', - exact: true, - }); - const point = chart.getByRole('button', { name: 'Wed: 210 ms', exact: true }); - if (isMobile) await point.tap(); - else await point.hover(); - await expect(page.getByRole('tooltip')).toBeVisible(); - await chart.locator('.altertable-trend-scroll').evaluate(element => { - if (element.scrollWidth <= element.clientWidth) - throw new Error('Expected an overflowing chart'); - element.scrollLeft = element.scrollLeft ? 0 : element.scrollWidth; - }); - await expect(page.getByRole('tooltip')).toHaveCount(0); -}); - -test('date picker hooks preserve segment and calendar keyboard focus', async ({ - page, -}) => { - await page.goto('/gallery?view=filters#dates'); - const picker = page.locator('#dates .altertable-date-range').first(); - await page.keyboard.press('Tab'); - const segment = picker.getByRole('spinbutton').first(); - await segment.focus(); - await expect(segment).toHaveCSS('outline-style', 'solid'); - await expect(segment).toHaveCSS('outline-offset', '-1px'); - await expect(picker.locator('[data-atbl-focus="group"]')).toHaveCSS( - 'outline-style', - 'solid' - ); - await picker - .getByRole('button', { name: 'Choose dates Gallery dates', exact: true }) - .press('Enter'); - const popover = page.locator('.altertable-date-range-popover'); - await expect(popover).toBeVisible(); - const cell = popover - .locator( - '.react-aria-CalendarCell:not([data-disabled]):not([data-unavailable]):not([data-outside-month])' - ) - .first(); - await cell.focus(); - await expect(cell).toHaveCSS('outline-style', 'solid'); - await expect(cell).toHaveCSS('outline-offset', '-1px'); - await page.keyboard.press('Escape'); - await expect(popover).not.toBeVisible(); -}); diff --git a/browser-tests/hooks.spec.ts b/browser-tests/hooks.spec.ts deleted file mode 100644 index 5d45fc16..00000000 --- a/browser-tests/hooks.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('operation switches isolate pending, failed, and cached data and evidence', async ({ - page, -}) => { - await page.goto('/hooks-app'); - await expect(page.getByTestId('state')).toHaveText('loading'); - await page.getByRole('button', { name: 'Resolve request' }).click(); - await expect(page.getByTestId('shown')).toHaveText('alpha'); - await page.getByRole('button', { name: 'Beta', exact: true }).click(); - await expect(page.getByTestId('state')).toHaveText('loading'); - await expect(page.getByTestId('shown')).toHaveText('none'); - await expect(page.getByTestId('evidence')).toHaveText('none'); - await page.getByRole('button', { name: 'Fail request' }).click(); - await expect(page.getByTestId('state')).toHaveText('error'); - await expect(page.getByTestId('shown')).toHaveText('none'); - await page.getByRole('button', { name: 'Alpha', exact: true }).click(); - await expect(page.getByTestId('shown')).toHaveText('alpha'); - await expect(page.getByTestId('evidence')).toHaveText('alpha-evidence'); - await page.getByRole('button', { name: 'Beta', exact: true }).click(); - await expect(page.getByTestId('shown')).toHaveText('none'); - await page.getByRole('button', { name: 'Resolve request' }).click(); - await expect(page.getByTestId('shown')).toHaveText('beta'); - await expect(page.getByTestId('evidence')).toHaveText('beta-evidence'); -}); - -test('input changes retain the original input and data through refresh and failure', async ({ - page, -}) => { - await page.goto('/hooks-app'); - await expect(page.getByTestId('narrative')).toHaveAttribute( - 'aria-busy', - 'true' - ); - await page.getByRole('button', { name: 'Resolve request' }).click(); - await expect(page.getByTestId('input')).toHaveText('1'); - await expect(page.getByTestId('narrative')).toContainText( - '1 for selection 1' - ); - await page.getByRole('button', { name: 'Change input' }).click(); - await expect(page.getByTestId('state')).toHaveText('updating'); - await expect(page.getByTestId('shown')).toHaveText('alpha'); - await expect(page.getByTestId('input')).toHaveText('1'); - await expect(page.getByTestId('narrative')).toContainText( - '1 for selection 1' - ); - await expect(page.getByTestId('narrative')).not.toContainText('selection 2'); - await page.getByRole('button', { name: 'Fail request' }).click(); - await expect(page.getByTestId('state')).toHaveText('stale-error'); - await expect(page.getByTestId('input')).toHaveText('1'); - await expect(page.getByTestId('narrative')).toContainText( - '1 for selection 1' - ); - await page.getByRole('button', { name: 'Change input' }).click(); - await page.getByRole('button', { name: 'Resolve request' }).click(); - await expect(page.getByTestId('state')).toHaveText('ready'); - await expect(page.getByTestId('input')).toHaveText('3'); - await expect(page.getByTestId('narrative')).toContainText( - '3 for selection 3' - ); - await page - .getByRole('button', { name: 'Explore Activity explained' }) - .click(); - await expect( - page.getByRole('dialog').locator('.altertable-text-content') - ).toHaveText('3 for selection 3'); -}); - -test('distinct clients isolate operation and facet results while the same client shares them', async ({ - page, -}) => { - await page.goto('/hooks-app?client-cache'); - await expect(page.locator('body')).toHaveAttribute( - 'data-a-metric-calls', - '1' - ); - await expect(page.locator('body')).toHaveAttribute( - 'data-b-metric-calls', - '1' - ); - await expect(page.locator('body')).toHaveAttribute('data-a-facet-calls', '1'); - await expect(page.locator('body')).toHaveAttribute('data-b-facet-calls', '1'); - await page.getByRole('button', { name: 'Resolve A', exact: true }).click(); - await expect(page.getByTestId('a').getByTestId('value')).toHaveText('11'); - await expect(page.getByTestId('a-again').getByTestId('value')).toHaveText( - '11' - ); - await expect(page.getByTestId('b').getByTestId('value')).toHaveText( - 'pending' - ); - await page.getByRole('button', { name: 'Resolve B', exact: true }).click(); - await expect(page.getByTestId('b').getByTestId('value')).toHaveText('22'); -}); - -test('cancelling one client leaves another client request pending and able to succeed', async ({ - page, -}) => { - await page.goto('/hooks-app?client-cache'); - await expect(page.locator('body')).toHaveAttribute( - 'data-a-metric-calls', - '1' - ); - await expect(page.locator('body')).toHaveAttribute( - 'data-b-metric-calls', - '1' - ); - await page - .getByTestId('a') - .getByRole('button', { name: 'Cancel', exact: true }) - .click(); - await expect(page.locator('body')).toHaveAttribute('data-a-aborted', 'true'); - await expect(page.locator('body')).not.toHaveAttribute( - 'data-b-aborted', - 'true' - ); - await page.getByRole('button', { name: 'Resolve B', exact: true }).click(); - await expect(page.getByTestId('b').getByTestId('value')).toHaveText('22'); -}); - -test('switching data clients clears previous-client data from the same observer', async ({ - page, -}) => { - await page.goto('/hooks-app?client-cache'); - await expect(page.locator('body')).toHaveAttribute( - 'data-b-metric-calls', - '1' - ); - await page.getByRole('button', { name: 'Resolve A', exact: true }).click(); - await expect(page.getByTestId('a').getByTestId('value')).toHaveText('11'); - await page - .getByRole('button', { name: 'Switch A client', exact: true }) - .click(); - await expect(page.getByTestId('a').getByTestId('value')).toHaveText( - 'pending' - ); - await page.getByRole('button', { name: 'Resolve B', exact: true }).click(); - await expect(page.getByTestId('a').getByTestId('value')).toHaveText('22'); -}); - -test('facet choices are fetched and displayed separately for each client', async ({ - page, -}) => { - await page.goto('/hooks-app?client-cache'); - await expect(page.locator('body')).toHaveAttribute('data-a-facet-calls', '1'); - await expect(page.locator('body')).toHaveAttribute('data-b-facet-calls', '1'); - await page - .getByTestId('a') - .getByRole('button', { name: 'Category: All', exact: true }) - .click(); - await expect( - page.getByRole('option', { name: 'A category', exact: true }) - ).toBeVisible(); - await expect( - page.getByRole('option', { name: 'B category', exact: true }) - ).toHaveCount(0); - await page - .getByRole('searchbox', { name: 'Search category values' }) - .press('Escape'); - await page.keyboard.press('Escape'); - await page - .getByTestId('b') - .getByRole('button', { name: 'Category: All', exact: true }) - .click(); - await expect( - page.getByRole('option', { name: 'B category', exact: true }) - ).toBeVisible(); - await expect( - page.getByRole('option', { name: 'A category', exact: true }) - ).toHaveCount(0); -}); - -test('declared primary sections reuse retained data when mounting during refresh and independent inspection uses its own queries', async ({ - page, -}) => { - await page.goto('/hooks-app?declared'); - await expect(page.locator('body')).toHaveAttribute( - 'data-alpha-requests', - '1' - ); - await expect(page.locator('body')).toHaveAttribute('data-beta-requests', '1'); - await page - .getByRole('button', { name: 'Resolve alpha 1', exact: true }) - .click(); - await page.getByRole('button', { name: 'Resolve beta', exact: true }).click(); - await expect( - page.getByTestId('secondary').filter({ visible: true }) - ).toHaveText('1'); - await page.getByRole('searchbox', { name: 'Version', exact: true }).fill('2'); - await expect(page.locator('body')).toHaveAttribute( - 'data-alpha-requests', - '2' - ); - await page.getByRole('button', { name: 'Show primary', exact: true }).click(); - await expect( - page.getByTestId('primary').filter({ visible: true }) - ).toHaveText('1 for 1'); - await expect(page.locator('body')).toHaveAttribute( - 'data-alpha-requests', - '2' - ); - await page.getByRole('button', { name: 'Explore Beta', exact: true }).click(); - const inspection = page.getByRole('dialog'); - await expect(inspection).toContainText('Independent beta'); - await expect(inspection).toContainText( - 'Only the beta section owns this glossary entry.' - ); - await inspection.getByRole('tab', { name: 'Queries', exact: true }).click(); - await expect(inspection).toContainText('SELECT beta'); - await expect(inspection).not.toContainText('SELECT alpha'); - await page.keyboard.press('Escape'); - await page - .getByRole('button', { name: 'Resolve alpha 2', exact: true }) - .click(); - await expect( - page.getByTestId('primary').filter({ visible: true }) - ).toHaveText('2 for 2'); -}); - -test('published dataset visualizations derive loading, share alternate views with inspection, and retain displayed rows', async ({ - page, -}) => { - await page.goto('/hooks-app?declared'); - await page.getByRole('button', { name: 'Show primary', exact: true }).click(); - await expect( - page.getByRole('heading', { name: 'Alpha visualization', exact: true }) - ).toBeVisible(); - await expect(page.getByTestId('primary-visual')).toHaveCount(0); - await expect( - page.getByRole('button', { - name: 'Explore Alpha visualization', - exact: true, - }) - ).toHaveCount(0); - await page - .getByRole('button', { name: 'Resolve alpha 1', exact: true }) - .click(); - await expect( - page.getByTestId('primary-visual').filter({ visible: true }) - ).toHaveText('1'); - await page.getByRole('tab', { name: 'Doubled', exact: true }).click(); - await expect( - page.getByTestId('primary-visual').filter({ visible: true }) - ).toHaveText('2'); - await page.getByRole('searchbox', { name: 'Version', exact: true }).fill('2'); - await expect( - page.getByTestId('primary-visual').filter({ visible: true }) - ).toHaveText('2'); - await page - .getByRole('button', { name: 'Explore Alpha visualization', exact: true }) - .click(); - const sheet = page.getByRole('dialog'); - await expect(sheet.getByTestId('primary-visual')).toHaveText('2'); - await sheet.getByRole('tab', { name: 'Count', exact: true }).click(); - await expect(sheet.getByTestId('primary-visual')).toHaveText('1'); - await sheet.getByRole('tab', { name: 'Queries', exact: true }).click(); - await expect(sheet).toContainText('SELECT alpha'); - await expect(sheet).not.toContainText('SELECT beta'); - await page.keyboard.press('Escape'); - await expect(sheet).not.toBeVisible(); - await expect( - page.getByTestId('primary-visual').filter({ visible: true }) - ).toHaveText('1'); - await page - .getByRole('button', { name: 'Resolve alpha 2', exact: true }) - .click(); - await expect( - page.getByTestId('primary-visual').filter({ visible: true }) - ).toHaveText('2'); -}); - -test('the standard toolbar has one activity label during loading and refresh', async ({ - page, -}) => { - await page.goto('/hooks-app?declared'); - const progress = page.locator('.altertable-refresh-control'); - await expect(progress).toHaveAttribute('data-refreshing', ''); - await expect(page.getByText('Loading data', { exact: true })).toHaveCount(1); - await page - .getByRole('button', { name: 'Resolve alpha 1', exact: true }) - .click(); - await expect(page.getByText('Loading data', { exact: true })).toHaveCount(0); - await page.getByRole('searchbox', { name: 'Version', exact: true }).fill('2'); - await expect(progress).toHaveAttribute('data-refreshing', ''); - await expect(page.getByText('Updating data', { exact: true })).toHaveCount(1); -}); diff --git a/browser-tests/inspection.spec.ts b/browser-tests/inspection.spec.ts deleted file mode 100644 index 83af7b47..00000000 --- a/browser-tests/inspection.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('repeated subjects and inspected widgets share one sheet', async ({ - page, -}) => { - await page.goto('/inspection-app'); - await page - .getByRole('button', { name: 'Explore Summary', exact: true }) - .click(); - await expect(page.locator('dialog[open]')).toHaveCount(1); - const sheet = page.getByRole('dialog', { name: 'Summary', exact: true }); - await expect(sheet).toBeVisible(); - await expect( - sheet.getByRole('button', { name: 'Explore Nested count' }) - ).toHaveCount(0); - await sheet.getByRole('tab', { name: 'Queries', exact: true }).click(); - await expect(sheet).toContainText('SELECT 1 AS count'); - await sheet.getByRole('button', { name: 'Close panel' }).click(); - await expect(page.locator('dialog[open]')).toHaveCount(0); - await page - .getByRole('button', { name: 'Update result', exact: true }) - .click(); - await page - .getByRole('button', { name: 'Explore Repeated count', exact: true }) - .click(); - await expect(page.locator('dialog[open]')).toHaveCount(1); - await expect( - page.getByRole('dialog', { name: 'Repeated count', exact: true }) - ).toContainText('2'); - await page.goBack(); - await expect(page.locator('dialog[open]')).toHaveCount(0); - await page.goForward(); - await expect(page.locator('dialog[open]')).toHaveCount(1); - await page.reload(); - await expect(page.locator('dialog[open]')).toHaveCount(1); -}); - -test('inspection headings use foreground color with a closely spaced optically aligned chevron', async ({ - page, - isMobile, -}, testInfo) => { - test.skip(isMobile, 'Hover is a desktop interaction'); - for (const colorScheme of ['light', 'dark'] as const) { - await page.emulateMedia({ colorScheme }); - await page.goto('/inspection-app'); - if (colorScheme === 'dark') - await page - .getByRole('button', { name: 'Switch to dark theme', exact: true }) - .click(); - await expect(page.locator('html')).toHaveCSS('color-scheme', colorScheme); - const heading = page.getByRole('button', { - name: 'Explore Repeated count', - exact: true, - }); - await heading.hover(); - await expect(heading).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - await expect(heading.locator('.altertable-widget-heading-label')).toHaveCSS( - 'text-decoration-line', - 'none' - ); - const foreground = await heading.evaluate( - element => - getComputedStyle(element.closest('.altertable-data-widget')!).color - ); - await expect(heading).toHaveCSS('color', foreground); - const label = await heading - .locator('.altertable-widget-heading-label') - .boundingBox(); - const chevron = await heading.locator('svg').boundingBox(); - expect( - Math.abs( - label!.y + label!.height / 2 + 1 - (chevron!.y + chevron!.height / 2) - ) - ).toBeLessThan(1); - expect(chevron!.x - (label!.x + label!.width)).toBeCloseTo(2, 0); - await page.screenshot({ - path: testInfo.outputPath(`data-app-heading-${colorScheme}.png`), - }); - } -}); - -test('controlled inspection waits for its owner to accept opening and closing', async ({ - page, -}) => { - await page.goto('/inspection-app?controlled'); - await page - .getByRole('button', { name: 'Controlled inspection', exact: true }) - .click(); - await expect(page.getByTestId('inspection-requests')).toHaveText('1'); - await expect(page.locator('dialog[open]')).toHaveCount(0); - await page.evaluate(() => { - history.pushState(null, '', '?controlled&about=controlled'); - window.dispatchEvent(new PopStateEvent('popstate')); - }); - await expect(page.getByTestId('inspection-requests')).toHaveText('2'); - await expect(page.locator('dialog[open]')).toHaveCount(0); - await page.getByRole('button', { name: 'Accept open', exact: true }).click(); - const sheet = page.getByRole('dialog', { name: 'Controlled', exact: true }); - await expect(sheet).toBeVisible(); - await sheet.getByRole('button', { name: 'Close panel', exact: true }).click(); - await expect(page.getByTestId('inspection-requests')).toHaveText('3'); - await expect(sheet).toBeVisible(); - await sheet - .getByRole('button', { name: 'Accept close', exact: true }) - .click(); - await expect(page.locator('dialog[open]')).toHaveCount(0); -}); diff --git a/browser-tests/layout.spec.ts b/browser-tests/layout.spec.ts deleted file mode 100644 index 18afb655..00000000 --- a/browser-tests/layout.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { expect, test } from '@playwright/test'; - -// These checks set iframe widths explicitly and do not depend on touch input. -test.skip( - ({ isMobile }) => isMobile, - 'Run geometry checks once per explicit width.' -); - -for (const width of [375, 1280]) { - for (const state of ['ready', 'loading', 'empty', 'error', 'stale']) { - test(`layout spacing at ${width}px in ${state}`, async ({ page }) => { - await page.setViewportSize({ width, height: 900 }); - await page.goto(`/layout?state=${state}`); - const app = page.frameLocator('iframe'); - const sections = app.getByTestId('sections'); - await expect(sections).toBeVisible(); - const geometry = await sections.evaluate(element => { - const gap = parseFloat(getComputedStyle(element).rowGap); - const body = element.closest('.altertable-app-body')!; - const main = element.closest('main')!; - const mainRect = main.getBoundingClientRect(); - const rects = [...element.children].map(child => - child.getBoundingClientRect() - ); - const following = element.nextElementSibling!.getBoundingClientRect(); - return { - gap, - gaps: rects - .slice(1) - .map((rect, index) => rect.top - rects[index]!.bottom), - mainGap: parseFloat(getComputedStyle(main).rowGap), - bodyGap: parseFloat(getComputedStyle(body).rowGap), - followingGap: following.top - element.getBoundingClientRect().bottom, - gutter: mainRect.left, - mainWidth: mainRect.width, - width: document.documentElement.clientWidth, - scrollWidth: document.documentElement.scrollWidth, - }; - }); - expect(geometry.gap).toBe(geometry.mainGap); - expect(geometry.bodyGap).toBe(geometry.mainGap); - expect(Math.abs(geometry.followingGap - geometry.bodyGap)).toBeLessThan( - 1 - ); - for (const gap of geometry.gaps) - expect(Math.abs(gap - geometry.gap)).toBeLessThan(1); - expect(geometry.mainWidth).toBeLessThanOrEqual(960); - expect(geometry.gutter).toBeGreaterThanOrEqual(width <= 600 ? 16 : 24); - expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.width); - if (['ready', 'loading', 'stale'].includes(state)) { - const grid = app.getByTestId('cards'); - await expect(grid).toHaveCSS('gap', `${geometry.gap}px`); - const rects = await grid.evaluate(element => - [...element.children].map(child => { - const rect = child.getBoundingClientRect(); - return { - x: rect.x, - y: rect.y, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - }; - }) - ); - if (width < 768) { - expect(rects[0]!.x).toBe(rects[1]!.x); - expect( - Math.abs(rects[1]!.y - rects[0]!.bottom - geometry.gap) - ).toBeLessThan(1); - } else { - expect(rects[0]!.y).toBe(rects[1]!.y); - expect( - Math.abs(rects[1]!.x - rects[0]!.right - geometry.gap) - ).toBeLessThan(1); - expect(rects[0]!.width).toBeGreaterThan(rects[1]!.width); - } - } - if (state === 'ready') - await page.screenshot({ - path: test.info().outputPath(`layout-${width}.png`), - }); - }); - } -} - -test('two-column spans collapse before a second track fits', async ({ - page, -}) => { - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('/layout?span&density=spacious'); - const grid = page.frameLocator('iframe').getByTestId('cards'); - for (const [size, threshold] of [ - ['compact', 414], - ['regular', 510], - ['wide', 670], - ] as const) { - for (const width of [threshold - 1, threshold]) { - await grid.evaluate( - (element, options) => { - element.setAttribute('data-min-item-width', options.size); - (element as HTMLElement).style.width = `${options.width}px`; - }, - { size, width } - ); - const geometry = await grid.evaluate(element => { - const gridRect = element.getBoundingClientRect(); - const [first, second] = [...element.children].map(child => - child.getBoundingClientRect() - ); - return { - gridWidth: gridRect.width, - firstWidth: first!.width, - secondWidth: second!.width, - rowGap: second!.top - first!.bottom, - gap: parseFloat(getComputedStyle(element).gap), - overflow: element.scrollWidth - element.clientWidth, - }; - }); - expect(geometry.overflow, `${size} at ${width}px`).toBeLessThan(1); - expect(Math.abs(geometry.firstWidth - geometry.gridWidth)).toBeLessThan( - 1 - ); - expect(Math.abs(geometry.rowGap - geometry.gap)).toBeLessThan(1); - if (width < threshold) - expect( - Math.abs(geometry.secondWidth - geometry.gridWidth) - ).toBeLessThan(1); - else - expect( - Math.abs(2 * geometry.secondWidth + geometry.gap - geometry.gridWidth) - ).toBeLessThan(1); - } - } -}); - -test('spans follow fitting tracks at custom public gap boundaries', async ({ - page, -}) => { - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('/layout?span'); - const grid = page.frameLocator('iframe').getByTestId('cards'); - for (const [size, minimum] of [ - ['compact', 192], - ['regular', 240], - ['wide', 320], - ] as const) { - for (const columns of [2, 3, 4]) { - for (const [gapName, gap] of [ - ['sm', 0], - ['md', 24], - ['lg', 80], - ] as const) { - for (const width of [ - 2 * minimum + gap - 1, - 2 * minimum + gap - 0.01, - 2 * minimum + gap, - 3 * minimum + 2 * gap - 1, - 3 * minimum + 2 * gap, - ]) { - await grid.evaluate( - (element, options) => { - element.setAttribute('data-min-item-width', options.size); - element.setAttribute('data-columns', String(options.columns)); - element.setAttribute('data-gap', options.gapName); - const style = (element as HTMLElement).style; - style.width = `${options.width}px`; - for (const name of [ - '--atbl-layout-gap', - '--atbl-space-sm', - '--atbl-space-md', - ]) - style.removeProperty(name); - style.setProperty( - options.gapName === 'lg' - ? '--atbl-layout-gap' - : `--atbl-space-${options.gapName}`, - `${options.gap}px` - ); - }, - { size, columns, gapName, gap, width } - ); - const geometry = await grid.evaluate(element => { - const [first, second] = [...element.children].map(child => - child.getBoundingClientRect() - ); - return { - first: first!.width, - second: second!.width, - columns: getComputedStyle(element).gridTemplateColumns, - overflow: element.scrollWidth - element.clientWidth, - }; - }); - const count = Math.min( - columns, - Math.floor((width + gap) / (minimum + gap)) - ); - const track = (width - (count - 1) * gap) / count; - const context = JSON.stringify({ - size, - columns, - gapName, - gap, - width, - geometry, - }); - expect(geometry.overflow, context).toBeLessThan(1); - expect(geometry.first, context).toBeCloseTo( - count === 1 ? width : 2 * track + gap, - 0 - ); - expect(geometry.second, context).toBeCloseTo( - count === 1 || (columns === 3 && count < 3) ? width : track, - 0 - ); - } - } - } - } -}); diff --git a/browser-tests/playground-annotations.spec.ts b/browser-tests/playground-annotations.spec.ts deleted file mode 100644 index 6cdb4bdd..00000000 --- a/browser-tests/playground-annotations.spec.ts +++ /dev/null @@ -1,641 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -async function openPlayground(page: Page) { - await page.route('**/__test/playground', async route => { - const bundle = await page.request.get('/__test/bundle'); - await route.fulfill({ - body: await bundle.text(), - contentType: 'text/javascript', - }); - }); - await page.goto('/playground'); - const trigger = page.getByRole('button', { name: 'Annotate', exact: true }); - await expect(trigger).toBeEnabled(); - await trigger.click(); - const frame = page.frameLocator('iframe'); - await expect( - frame.getByRole('button', { name: 'Annotation selection' }) - ).toBeVisible(); - return frame; -} -async function addAnnotation(page: Page, id: string, text: string) { - const frame = page.frameLocator('iframe'); - await expect( - frame.getByRole('button', { name: 'Annotation selection' }) - ).toBeVisible(); - await frame.locator(`[data-annotation-id="${id}"]`).click({ force: true }); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await input.fill(text); - await input.press('Enter'); - await expect(input).toHaveCount(0); - await expect( - page.getByRole('button', { name: 'Send annotations', exact: true }) - ).toBeEnabled(); -} -async function review(page: Page) { - await page - .getByRole('button', { name: 'Review annotations', exact: true }) - .click(); - return page.getByRole('dialog', { name: 'Review annotations' }); -} - -test('activation leaves widgets unselected until deliberate navigation', async ({ - page, -}) => { - const frame = await openPlayground(page); - const selection = frame.getByRole('button', { name: 'Annotation selection' }); - const outline = frame.locator('.altertable-annotation-outline'); - await expect(selection).toBeFocused(); - await expect(outline).toHaveCount(0); - await selection.press('Enter'); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toHaveCount(0); - await selection.press('ArrowRight'); - await expect(outline).toBeVisible(); - await selection.press('Enter'); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toBeFocused(); -}); - -test('bottom bar reviews, focuses, hides and deletes saved annotations', async ({ - page, -}, testInfo) => { - const frame = await openPlayground(page); - const bar = page.getByRole('toolbar', { name: 'Annotations', exact: true }); - await expect(bar).toHaveCount(0); - await expect(frame.locator('.altertable-annotation-hint')).toBeVisible(); - await expect( - frame.getByRole('button', { name: 'Annotate', exact: true }) - ).toHaveCount(0); - await addAnnotation(page, 'monthly-revenue', 'Compare with last year'); - await addAnnotation(page, 'customers', 'Show active customers'); - await expect(bar).toContainText('Annotating · 2'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); - await page - .getByRole('button', { name: 'Hide annotation pins', exact: true }) - .click(); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toHaveCount(0); - await expect( - page.getByRole('button', { name: 'Focus annotation', exact: true }) - ).toHaveCount(0); - await page - .getByRole('button', { name: 'Show annotation pins', exact: true }) - .click(); - await frame - .getByRole('button', { name: 'Annotation 1', exact: true }) - .click(); - await expect( - frame.getByRole('textbox', { name: 'Annotation text' }) - ).toHaveValue('Compare with last year'); - await expect( - frame.getByRole('button', { name: 'Annotation 1', exact: true }) - ).toBeVisible(); - const panel = await review(page); - await expect(panel.getByLabel('Annotation drafts')).toContainText( - 'Show active customers' - ); - const row = await panel - .locator('.altertable-annotation-bar-row') - .first() - .boundingBox(); - const remove = await panel - .getByRole('button', { name: 'Delete annotation 1', exact: true }) - .boundingBox(); - expect( - Math.abs(remove!.y + remove!.height / 2 - row!.y - row!.height / 2) - ).toBeLessThan(1); - await page.screenshot({ - path: testInfo.outputPath('annotation-bar-review.png'), - }); - await panel - .getByRole('button', { name: 'Delete annotation 1', exact: true }) - .click(); - await expect(bar).toContainText('Annotating · 1'); - await panel - .getByRole('button', { name: 'Open annotation 1', exact: true }) - .click(); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toHaveValue('Show active customers'); - await input.press('Escape'); - await expect(bar).toBeVisible(); - await frame - .getByRole('button', { name: 'Annotation selection' }) - .press('Escape'); - await expect(bar).toHaveCount(0); - await page.getByRole('button', { name: 'Annotate', exact: true }).click(); - await page - .getByRole('button', { name: 'Discard all annotations', exact: true }) - .click(); - const confirmation = page.getByRole('dialog', { - name: 'Discard all pending annotations?', - }); - await expect(confirmation).toBeVisible(); - await expect( - confirmation.getByRole('button', { name: 'Cancel', exact: true }) - ).toBeFocused(); - await page.screenshot({ - path: testInfo.outputPath('annotation-discard.png'), - animations: 'disabled', - }); - await confirmation - .getByRole('button', { name: 'Cancel', exact: true }) - .click(); - await expect(confirmation).toBeHidden(); - await expect(bar).toContainText('Annotating · 1'); - await page - .getByRole('button', { name: 'Discard all annotations', exact: true }) - .click(); - await confirmation - .getByRole('button', { name: 'Discard', exact: true }) - .click(); - await expect(bar).toHaveCount(0); - await expect( - page.getByRole('button', { name: 'Annotate', exact: true }) - ).toHaveAttribute('aria-pressed', 'false'); -}); - -test('unsaved input gates batch Send and only dirty edits require two Escapes', async ({ - page, -}) => { - const frame = await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Compare last year'); - await frame - .getByRole('button', { name: 'Annotation 1', exact: true }) - .click(); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toHaveValue('Compare last year'); - await expect( - page.getByRole('button', { name: 'Send annotations', exact: true }) - ).toBeEnabled(); - await input.fill('Unsaved edit'); - await expect( - page.getByRole('button', { name: 'Send annotations', exact: true }) - ).toBeDisabled(); - await input.press('Escape'); - await expect( - frame.getByRole('region', { name: 'Annotation editor' }) - ).toHaveCSS('animation-name', 'annotation-shake'); - await expect(input).toHaveValue('Unsaved edit'); - await input.press('Escape'); - await expect(input).toHaveCount(0); - await frame - .getByRole('button', { name: 'Annotation selection' }) - .press('Escape'); - await page.getByRole('button', { name: 'Annotate', exact: true }).click(); - const panel = await review(page); - await panel - .getByRole('button', { name: 'Open annotation 1', exact: true }) - .click(); - await expect(input).toHaveValue('Compare last year'); - await input.press('Escape'); - await expect(input).toHaveCount(0); -}); - -test('batch Send calls outer host API, preserves drafts on failure and retries the snapshot', async ({ - page, -}, testInfo) => { - const submitted: unknown[] = []; - let allowSubmission = false; - await page.route('**/api/annotations', async route => { - submitted.push(route.request().postDataJSON()); - await route.fulfill({ - status: allowSubmission ? 200 : 503, - contentType: 'application/json', - body: JSON.stringify({ acceptedCount: 2 }), - }); - }); - const frame = await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Compare with last year'); - await addAnnotation(page, 'customers', 'Show active customers'); - const send = page.getByRole('button', { - name: 'Send annotations', - exact: true, - }); - await send.click(); - await expect(page.getByRole('alert')).toHaveText( - 'Could not send annotations. Try again.' - ); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toContainText('Annotating · 2'); - await expect( - frame.getByRole('button', { name: 'Annotation 2', exact: true }) - ).toBeVisible(); - await page.screenshot({ - path: testInfo.outputPath('annotation-send-retry.png'), - }); - allowSubmission = true; - await send.click(); - await expect(page.getByLabel('Annotation submission')).toHaveText( - 'Sent 2 annotations to the preview host.' - ); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toHaveCount(0); - expect(submitted).toHaveLength(2); - expect(submitted[1]).toEqual(submitted[0]); - const batch = submitted[0] as { - annotations: { - id: string; - comment: string; - target: { id: string; queryNames: string[] }; - }[]; - }; - expect(batch.annotations.map(annotation => annotation.comment)).toEqual([ - 'Compare with last year', - 'Show active customers', - ]); - expect(batch.annotations[0]!.target.queryNames).toEqual(['revenue']); - await page.getByRole('button', { name: 'Annotate', exact: true }).click(); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toHaveCount(0); -}); - -test('bar matches inverse theme, stays within viewport and moves by keyboard', async ({ - page, -}, testInfo) => { - const frame = await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Compare last year'); - const bar = page.getByRole('toolbar', { name: 'Annotations', exact: true }); - const viewport = page.viewportSize()!; - const before = await bar.boundingBox(); - expect(before!.y).toBeGreaterThan(viewport.height / 2); - expect(before!.x).toBeGreaterThanOrEqual(0); - expect(before!.x + before!.width).toBeLessThanOrEqual(viewport.width); - await page - .getByRole('button', { name: 'Move annotation bar', exact: true }) - .press('ArrowUp'); - const after = await bar.boundingBox(); - expect(after!.y).toBeCloseTo(before!.y - 10, 0); - await expect(bar).toHaveCSS('background-color', 'rgb(242, 243, 244)'); - await page.screenshot({ - path: testInfo.outputPath('annotation-bar-dark.png'), - }); - await page - .getByRole('button', { name: 'Switch to light theme', exact: true }) - .click(); - await expect(bar).toHaveCSS('background-color', 'rgb(32, 35, 38)'); - await frame - .getByRole('button', { name: 'Annotation 1', exact: true }) - .click(); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toHaveAttribute( - 'placeholder', - 'Describe what to change…' - ); - await expect( - frame.getByRole('button', { name: 'Save annotation', exact: true }) - ).toHaveCSS('border-radius', '50%'); - await page.screenshot({ - path: testInfo.outputPath('annotation-bar-light.png'), - }); -}); - -test('submission locks editor mutations until the host responds', async ({ - page, -}) => { - let finishRequest: (() => Promise) | undefined; - let requestArrived: (() => void) | undefined; - const received = new Promise(resolve => { - requestArrived = resolve; - }); - await page.route('**/api/annotations', async route => { - finishRequest = () => route.fulfill({ status: 503, body: 'Try again' }); - requestArrived?.(); - }); - const frame = await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Compare last year'); - await frame - .getByRole('button', { name: 'Annotation 1', exact: true }) - .click(); - const input = frame.getByRole('textbox', { name: 'Annotation text' }); - await expect(input).toHaveValue('Compare last year'); - await page - .getByRole('button', { name: 'Send annotations', exact: true }) - .click(); - await received; - await expect(input).toBeDisabled(); - await expect( - page.getByRole('button', { name: 'Discard all annotations', exact: true }) - ).toBeDisabled(); - await expect( - page.getByRole('button', { name: 'Exit annotation mode', exact: true }) - ).toBeDisabled(); - await finishRequest!(); - await expect(page.getByRole('alert')).toHaveText( - 'Could not send annotations. Try again.' - ); - await expect(input).toBeEnabled(); - await expect(input).toHaveValue('Compare last year'); -}); - -test('hovering never shifts controls and both themes keep readable contrast', async ({ - page, -}, testInfo) => { - await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Compare last year'); - const bar = page.getByRole('toolbar', { name: 'Annotations', exact: true }); - function luminance(channels: number[]) { - const rgb = channels.map(channel => { - const normalized = channel / 255; - return normalized <= 0.04045 - ? normalized / 12.92 - : ((normalized + 0.055) / 1.055) ** 2.4; - }); - return rgb[0]! * 0.2126 + rgb[1]! * 0.7152 + rgb[2]! * 0.0722; - } - for (const theme of ['dark', 'light']) { - if (theme === 'light') - await page - .getByRole('button', { name: 'Switch to light theme', exact: true }) - .click(); - const send = bar.getByRole('button', { - name: 'Send annotations', - exact: true, - }); - await expect(send).toHaveCSS('font-weight', '600'); - await page.screenshot({ - path: testInfo.outputPath(`annotation-send-${theme}.png`), - }); - const buttons = bar.getByRole('button'); - const before = await buttons.evaluateAll(elements => - elements.map(element => { - const rect = element.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; - }) - ); - for (const button of await buttons.all()) { - await button.hover(); - const after = await buttons.evaluateAll(elements => - elements.map(element => { - const rect = element.getBoundingClientRect(); - return { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - }; - }) - ); - expect(after).toEqual(before); - const colors = await button.evaluate(element => { - const style = getComputedStyle(element); - const parent = element.closest('[role=toolbar]')!; - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const context = canvas.getContext('2d')!; - context.fillStyle = getComputedStyle(parent).backgroundColor; - context.fillRect(0, 0, 1, 1); - context.fillStyle = style.backgroundColor; - context.fillRect(0, 0, 1, 1); - const background = [...context.getImageData(0, 0, 1, 1).data].slice( - 0, - 3 - ); - context.clearRect(0, 0, 1, 1); - context.fillStyle = style.color; - context.fillRect(0, 0, 1, 1); - const foreground = [...context.getImageData(0, 0, 1, 1).data].slice( - 0, - 3 - ); - return { - label: element.getAttribute('aria-label'), - foreground, - background, - }; - }); - const first = luminance(colors.foreground); - const second = luminance(colors.background); - expect( - (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05), - JSON.stringify({ theme, ...colors }) - ).toBeGreaterThanOrEqual(4.5); - } - } -}); - -test('review displays screenshot thumbnails and an enlarged captured area', async ({ - page, -}, testInfo) => { - await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Move this chart'); - const panel = await review(page); - const thumbnail = panel.getByRole('button', { - name: 'View screenshot of Revenue by month', - }); - await expect(thumbnail.locator('img')).toHaveCSS('object-fit', 'contain'); - await expect(thumbnail.locator('img')).toHaveAttribute( - 'src', - /^data:image\/png;base64,/ - ); - await thumbnail.click(); - const preview = page.getByRole('dialog', { name: 'Revenue by month' }); - await expect( - preview.getByRole('img', { name: 'Captured area for Revenue by month' }) - ).toBeVisible(); - await page.screenshot({ - path: testInfo.outputPath('annotation-screenshot-preview.png'), - animations: 'disabled', - }); - await preview.press('Escape'); - await expect(preview).not.toBeVisible(); -}); - -test('playground recovers annotations after reload and supports undoing deletion', async ({ - page, -}) => { - await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Keep this feedback'); - await page.reload(); - const trigger = page.getByRole('button', { name: 'Annotate', exact: true }); - await expect(trigger).toBeEnabled(); - await expect(trigger).toContainText('1'); - await trigger.click(); - const panel = await review(page); - await expect(panel).toContainText('Keep this feedback'); - await panel - .getByRole('button', { name: 'Delete annotation 1', exact: true }) - .click(); - await expect( - page.getByRole('button', { name: 'Undo', exact: true }) - ).toBeVisible(); - await page.getByRole('button', { name: 'Undo', exact: true }).click(); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toContainText('1'); - await expect - .poll(() => - page.evaluate(async () => { - const database = await new Promise(resolve => { - const request = indexedDB.open('altertable-annotations', 1); - request.onsuccess = () => resolve(request.result); - }); - const count = await new Promise(resolve => { - const request = database - .transaction('drafts') - .objectStore('drafts') - .get(`playground:${location.origin}`); - request.onsuccess = () => - resolve(request.result?.drafts?.length ?? 0); - }); - database.close(); - return count; - }) - ) - .toBe(1); - await page.reload(); - await expect(trigger).toBeEnabled(); - await expect(trigger).toContainText('1'); -}); - -test('a new app version clears old annotations and allows collecting a fresh batch', async ({ - page, -}) => { - await openPlayground(page); - await addAnnotation(page, 'monthly-revenue', 'Earlier version feedback'); - await page.evaluate(async () => { - const database = await new Promise((resolve, reject) => { - const request = indexedDB.open('altertable-annotations', 1); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - await new Promise((resolve, reject) => { - const transaction = database.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - const key = `playground:${location.origin}`; - const request = store.get(key); - request.onsuccess = () => - store.put({ ...request.result, sourceVersion: 'older-source' }, key); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); - database.close(); - }); - await page.reload(); - const trigger = page.getByRole('button', { name: 'Annotate', exact: true }); - await expect(trigger).toBeEnabled(); - await expect(trigger).not.toContainText('1'); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toHaveCount(0); - await trigger.click(); - await addAnnotation(page, 'customers', 'Feedback for the new version'); - await expect(trigger).toContainText('1'); -}); - -test('annotation review is centered above its floating bar', async ({ - page, -}) => { - await openPlayground(page); - await addAnnotation(page, 'customers', 'Show active customers'); - const panel = await review(page); - const bounds = await panel.boundingBox(); - const bar = await page - .getByRole('toolbar', { name: 'Annotations', exact: true }) - .boundingBox(); - expect( - Math.abs(bounds!.x + bounds!.width / 2 - bar!.x - bar!.width / 2) - ).toBeLessThan(2); -}); - -test('blocked local storage retains usable in-memory feedback and reports recovery unavailable', async ({ - page, -}) => { - await page.addInitScript(() => { - Object.defineProperty(window, 'indexedDB', { - configurable: true, - value: { - open() { - throw new Error('Local storage blocked'); - }, - }, - }); - }); - await openPlayground(page); - await addAnnotation(page, 'customers', 'Keep working without local storage'); - await expect(page.getByRole('alert')).toContainText( - 'Local annotation recovery is unavailable' - ); - await expect( - page.getByRole('button', { name: 'Send annotations', exact: true }) - ).toBeEnabled(); - const panel = await review(page); - await expect(panel).toContainText('Keep working without local storage'); -}); - -test('review minimizes, trash is actionable, tooltips layer above review and Send uses annotation colors', async ({ - page, -}, testInfo) => { - await openPlayground(page); - await addAnnotation(page, 'customers', 'Show active customers'); - const panel = await review(page); - await expect( - panel.getByRole('button', { name: 'Delete annotation 1', exact: true }) - ).toHaveCSS('cursor', 'pointer'); - const discard = page.getByRole('button', { - name: 'Discard all annotations', - exact: true, - }); - await expect(discard).toHaveCSS('cursor', 'pointer'); - await discard.hover(); - const tooltip = page.getByRole('tooltip', { - name: 'Discard all annotations', - exact: true, - }); - await expect(tooltip).toBeVisible(); - expect( - Number(await tooltip.evaluate(element => getComputedStyle(element).zIndex)) - ).toBeGreaterThan( - Number(await panel.evaluate(element => getComputedStyle(element).zIndex)) - ); - await page.screenshot({ - path: testInfo.outputPath('annotation-tooltip-over-review.png'), - }); - const send = page.getByRole('button', { - name: 'Send annotations', - exact: true, - }); - await expect(send).toHaveCSS('background-color', 'rgb(192, 38, 211)'); - await expect(send).toHaveCSS('color', 'rgb(255, 255, 255)'); - await panel - .getByRole('button', { name: 'Minimize annotation review', exact: true }) - .click(); - await expect(panel).toHaveCount(0); - const frame = page.frameLocator('iframe'); - const pin = frame.getByRole('button', { name: 'Annotation 1', exact: true }); - await expect(pin).toHaveCSS('background-color', 'rgb(192, 38, 211)'); - await pin.click(); - await expect(frame.locator('.altertable-annotation-outline')).toHaveCSS( - 'border-color', - 'rgb(192, 38, 211)' - ); - await expect( - frame.getByRole('button', { name: 'Save annotation', exact: true }) - ).toHaveCSS('background-color', 'rgb(192, 38, 211)'); - await expect( - page.getByRole('toolbar', { name: 'Annotations', exact: true }) - ).toBeVisible(); - await page - .getByRole('button', { name: 'Switch to light theme', exact: true }) - .click(); - await expect(send).toHaveCSS('background-color', 'rgb(192, 38, 211)'); - await expect(send).toHaveCSS('color', 'rgb(255, 255, 255)'); - await page.locator('.playground-host').evaluate(element => { - element.style.setProperty('--atbl-accent', '#00ff00'); - element.style.setProperty('--atbl-on-accent', '#000000'); - }); - await expect(send).toHaveCSS('background-color', 'rgb(192, 38, 211)'); - await expect(send).toHaveCSS('color', 'rgb(255, 255, 255)'); - await page.screenshot({ - path: testInfo.outputPath('annotation-magenta-light.png'), - }); -}); diff --git a/browser-tests/playwright.config.ts b/browser-tests/playwright.config.ts deleted file mode 100644 index 7f1f51ed..00000000 --- a/browser-tests/playwright.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -const port = Number(process.env.DATA_APP_TEST_PORT ?? 27418); -export default defineConfig({ - testDir: '.', - testMatch: '*.spec.ts', - workers: 1, - use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure' }, - projects: [ - { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, - { - name: 'phone', - use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' }, - }, - ], - webServer: { - command: 'bun server.ts', - url: `http://127.0.0.1:${port}`, - reuseExistingServer: false, - }, -}); diff --git a/browser-tests/present.spec.ts b/browser-tests/present.spec.ts deleted file mode 100644 index 8d2c3c62..00000000 --- a/browser-tests/present.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('a hosted story enters fullscreen and restores the exploration on close', async ({ - page, -}) => { - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - const launch = app.getByRole('button', { - name: 'Present story', - exact: true, - }); - await launch.click(); - await expect(page.locator('iframe:fullscreen')).toBeVisible(); - await expect(app.locator('html:fullscreen')).toBeVisible(); - const story = app.getByRole('dialog'); - await expect( - story.getByRole('heading', { name: 'Total samples: 3' }) - ).toBeVisible(); - await expect(page).toHaveURL(/present=1.*step=total-samples/); - await story.getByRole('button', { name: 'Next step', exact: true }).click(); - await expect( - story.getByRole('heading', { name: 'Counts by group' }) - ).toBeVisible(); - await expect(story).toHaveCSS('opacity', '1'); - await page.screenshot({ path: test.info().outputPath('fullscreen.png') }); - await story - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - await expect(page.locator(':fullscreen')).toHaveCount(0); - await expect(story).toHaveCount(0); - await expect(page).not.toHaveURL(/present=1/); - await expect(launch).toBeFocused(); -}); - -test('leaving browser fullscreen closes the story and clears its route', async ({ - page, -}) => { - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - const launch = app.getByRole('button', { - name: 'Present story', - exact: true, - }); - await launch.click(); - await expect(app.locator('html:fullscreen')).toBeVisible(); - await app - .locator('html') - .evaluate(element => element.ownerDocument.exitFullscreen()); - await expect(app.getByRole('dialog')).toHaveCount(0); - await expect(page).not.toHaveURL(/present=1/); - await expect(launch).toBeFocused(); -}); - -test('a saved presentation opens without fullscreen or a user gesture', async ({ - page, -}) => { - await page.goto('/starter-data-app?present=1&step=counts-by-group'); - const app = page.frameLocator('iframe'); - await expect( - app.getByRole('dialog').getByRole('heading', { name: 'Counts by group' }) - ).toBeVisible(); - await expect(app.locator(':fullscreen')).toHaveCount(0); - await expect(page.locator(':fullscreen')).toHaveCount(0); - await page.keyboard.press('Escape'); - await expect(app.getByRole('dialog')).toHaveCount(0); - await expect(page).not.toHaveURL(/present=1/); -}); - -test('a host denying fullscreen still allows the story to open and close', async ({ - page, -}) => { - await page.goto('/starter-data-app?no-fullscreen=1'); - const app = page.frameLocator('iframe'); - await app.getByRole('button', { name: 'Present story', exact: true }).click(); - const story = app.getByRole('dialog'); - await expect( - story.getByRole('heading', { name: 'Total samples: 3' }) - ).toBeVisible(); - await expect(page.locator(':fullscreen')).toHaveCount(0); - await story - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - await expect(story).toHaveCount(0); - await expect(page).not.toHaveURL(/present=1/); -}); - -test('closing a story leaves fullscreen entered by another control intact', async ({ - page, -}) => { - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - await app - .getByRole('button', { name: 'Present story', exact: true }) - .waitFor(); - await page - .getByRole('button', { name: 'Change theme', exact: true }) - .evaluate(button => { - button.addEventListener( - 'click', - () => void document.documentElement.requestFullscreen(), - { once: true } - ); - }); - await page.getByRole('button', { name: 'Change theme', exact: true }).click(); - await expect(page.locator('html:fullscreen')).toBeVisible(); - await app.getByRole('button', { name: 'Present story', exact: true }).click(); - await app - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - await expect(page.locator('html:fullscreen')).toBeVisible(); - await page.evaluate(() => document.exitFullscreen()); -}); - -test('a rejected fullscreen request falls back to the presentation modal', async ({ - page, -}) => { - await page.goto('/starter-data-app'); - const app = page.frameLocator('iframe'); - const launch = app.getByRole('button', { - name: 'Present story', - exact: true, - }); - await launch.evaluate(button => { - button.ownerDocument.documentElement.requestFullscreen = () => - Promise.reject(new TypeError('Fullscreen request denied')); - }); - await launch.click(); - const story = app.getByRole('dialog'); - await expect( - story.getByRole('heading', { name: 'Total samples: 3' }) - ).toBeVisible(); - await expect(app.locator(':fullscreen')).toHaveCount(0); - await story - .getByRole('button', { name: 'Exit presentation', exact: true }) - .click(); - await expect(story).toHaveCount(0); -}); diff --git a/browser-tests/runtime-errors.spec.ts b/browser-tests/runtime-errors.spec.ts deleted file mode 100644 index 81b180fe..00000000 --- a/browser-tests/runtime-errors.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const kind of ['rejection', 'error'] as const) { - test(`unrelated ${kind} leaves the app visible and connected`, async ({ - page, - }) => { - await page.goto('/bundle-host'); - const app = page.frameLocator('iframe'); - await expect( - app.getByRole('button', { name: 'Query', exact: true }) - ).toBeVisible(); - const frame = page - .frames() - .find(frame => frame.url().includes('/__test/runtime'))!; - const reported = page.waitForEvent('pageerror'); - await frame.evaluate(kind => { - const script = document.createElement('script'); - script.textContent = - kind === 'rejection' - ? 'Promise.reject(new Error("Extension connection failed"));\n//# sourceURL=inpage.js' - : 'throw new Error("Injected script failed");\n//# sourceURL=inpage.js'; - document.body.append(script); - }, kind); - await reported; - await expect(page.locator('iframe')).toBeVisible(); - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":1'); - await expect(page.getByRole('alert')).toHaveCount(0); - await expect(page.locator('iframe')).toBeVisible(); - }); -} - -test('uncaught React rendering failures hide the app and offer retry', async ({ - page, -}) => { - await page.goto('/bundle-host'); - await page - .frameLocator('iframe') - .getByRole('button', { name: 'Crash render' }) - .click(); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await expect(page.locator('iframe')).toBeHidden(); - await page.getByRole('button', { name: 'Retry', exact: true }).click(); - await expect( - page - .frameLocator('iframe') - .getByRole('button', { name: 'Query', exact: true }) - ).toBeVisible(); -}); - -for (const failure of ['syntax', 'execution']) { - test(`bundle ${failure} failures hide the app`, async ({ page }) => { - await page.goto(`/bundle-host?broken=1&${failure}=1`); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await expect(page.locator('iframe')).toBeHidden(); - }); -} - -test('delayed exceptions from the app bundle remain fatal', async ({ - page, -}) => { - await page.goto('/bundle-host'); - await page - .frameLocator('iframe') - .getByRole('button', { name: 'Crash callback' }) - .click(); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await expect(page.locator('iframe')).toBeHidden(); -}); - -test('unhandled rejections from the app bundle remain fatal', async ({ - page, -}) => { - await page.goto('/bundle-host'); - await page - .frameLocator('iframe') - .getByRole('button', { name: 'Reject promise' }) - .click(); - await expect(page.getByRole('alert')).toContainText('Could not load'); - await expect(page.locator('iframe')).toBeHidden(); -}); - -test('unattributed rejections remain diagnostics', async ({ page }) => { - await page.goto('/bundle-host'); - const app = page.frameLocator('iframe'); - await expect( - app.getByRole('button', { name: 'Query', exact: true }) - ).toBeVisible(); - const frame = page - .frames() - .find(frame => frame.url().includes('/__test/runtime'))!; - const reported = page.waitForEvent('pageerror'); - await frame.evaluate(() => { - void Promise.reject('Unknown rejection'); - }); - await reported; - await app.getByRole('button', { name: 'Query', exact: true }).click(); - await expect(app.locator('#result')).toContainText('"version":1'); - await expect(page.getByRole('alert')).toHaveCount(0); -}); diff --git a/browser-tests/skeleton.spec.ts b/browser-tests/skeleton.spec.ts deleted file mode 100644 index 92aec518..00000000 --- a/browser-tests/skeleton.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('startup skeleton adapts to host width, theme, and reduced motion', async ({ - page, -}) => { - await page.goto('/skeleton'); - const styles = page.locator('style[data-altertable-shell-styles]'); - await expect(styles).toHaveCount(1); - expect( - await styles.evaluate(element => (element as HTMLStyleElement).nonce) - ).toBe('shell-test'); - await expect(page.locator('style[data-altertable-styles]')).toHaveCount(0); - const status = page.getByRole('status', { name: 'Loading fixture app' }); - await expect(status).toBeVisible(); - await expect(status).toHaveAttribute('aria-busy', 'true'); - await expect(page.getByRole('button')).toHaveCount(0); - const cards = page.locator('.altertable-metric-widget'); - await expect(cards).toHaveCount(3); - const wide = await cards.evaluateAll(elements => - elements.map(element => element.getBoundingClientRect().y) - ); - if (page.viewportSize()!.width >= 800) expect(new Set(wide).size).toBe(1); - const lightBackground = await status.evaluate( - element => getComputedStyle(element).backgroundColor - ); - - await page.emulateMedia({ reducedMotion: 'reduce' }); - await page.goto('/skeleton?dark=1&narrow=1'); - await expect(status).toBeVisible(); - const narrow = await cards.evaluateAll(elements => - elements.map(element => element.getBoundingClientRect().y) - ); - expect(new Set(narrow).size).toBe(3); - expect( - await status.evaluate(element => element.scrollWidth <= element.clientWidth) - ).toBe(true); - expect( - await status.evaluate(element => getComputedStyle(element).backgroundColor) - ).not.toBe(lightBackground); - expect( - await page - .locator('.altertable-skeleton') - .first() - .evaluate(element => getComputedStyle(element).animationName) - ).not.toContain('pulse'); -}); - -test('header and footer slots remain accessible while widget placeholders are hidden', async ({ - page, -}) => { - await page.goto('/skeleton?header=1&footer=1'); - await expect( - page.getByRole('heading', { name: 'Activity report' }) - ).toBeVisible(); - await expect( - page.getByRole('link', { name: 'About this report' }) - ).toBeVisible(); - await expect(page.getByRole('heading', { level: 2 })).toHaveCount(0); - await page.goto('/skeleton'); - await expect( - page.locator('.altertable-data-app-skeleton-header') - ).toHaveCount(0); - await expect( - page.locator('.altertable-data-app-skeleton-footer') - ).toHaveCount(0); -}); diff --git a/browser-tests/style-stress.spec.ts b/browser-tests/style-stress.spec.ts deleted file mode 100644 index 9b21888f..00000000 --- a/browser-tests/style-stress.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { expect, test } from '@playwright/test'; - -for (const width of [320, 375, 768, 1280]) { - for (const enlarged of [false, true]) { - test(`controls and exact values stay contained at ${width}px${enlarged ? ' with enlarged text' : ''}`, async ({ - page, - isMobile, - }) => { - test.skip( - isMobile, - 'Explicit viewport sizes cover responsive text stress.' - ); - await page.setViewportSize({ width, height: 900 }); - await page.goto(`/style-stress${enlarged ? '?dark' : ''}`); - if (enlarged) - await page.addStyleTag({ content: 'html { font-size: 150%; }' }); - await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); - const dimensions = await page.evaluate(() => ({ - width: document.documentElement.clientWidth, - scroll: document.documentElement.scrollWidth, - })); - expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width + 1); - const controls = page.locator('.altertable-variable-bar'); - const escaped = await controls.evaluate(element => { - const outer = element.getBoundingClientRect(); - return [...element.children] - .filter(child => { - const inner = child.getBoundingClientRect(); - return ( - inner.width > 0 && - (inner.left < outer.left - 1 || inner.right > outer.right + 1) - ); - }) - .map(child => child.className); - }); - expect(escaped).toEqual([]); - const wrapper = page.locator('.altertable-data-table-scroll'); - expect( - await wrapper.evaluate( - element => element.scrollWidth > element.clientWidth - ) - ).toBe(true); - await page - .getByRole('button', { - name: 'Choose dates Reporting dates, compared with previous period', - exact: true, - }) - .press('Enter'); - const popover = page.locator('.altertable-date-range-popover'); - await expect(popover).toBeVisible(); - const bounds = await popover.boundingBox(); - expect(bounds!.x).toBeGreaterThanOrEqual(-1); - expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width + 1); - await page.keyboard.press('Escape'); - await expect(popover).not.toBeVisible(); - await page - .getByRole('button', { - name: 'Toggle the value loading state without removing its heading', - }) - .click(); - await expect( - page.getByRole('heading', { - name: 'A precisely formatted amount with an exceptionally long descriptive label', - }) - ).toBeVisible(); - await expect( - page.locator('.altertable-metric-value > .altertable-skeleton') - ).toBeVisible(); - await page - .getByRole('button', { - name: 'Toggle the value loading state without removing its heading', - }) - .click(); - expect( - await page.evaluate( - () => - document.documentElement.scrollWidth <= - document.documentElement.clientWidth + 1 - ) - ).toBe(true); - await page.screenshot({ - path: `/tmp/style-stress-${width}-${enlarged ? 'enlarged' : 'default'}.png`, - fullPage: true, - }); - }); - } -} - -test('shared selected paint overrides real menu and date-preset surfaces', async ({ - page, -}) => { - await page.clock.setFixedTime(new Date('2026-09-30T12:00:00Z')); - await page.goto('/style-stress'); - await page.addStyleTag({ - content: ':root { --atbl-control-selected-surface: #123456; }', - }); - const trigger = page.getByRole('button', { - name: /^Organization and geographic reporting region:/, - }); - await trigger.click(); - const option = page.getByRole('option').first(); - await expect(option).toHaveAttribute('aria-selected', 'true'); - await expect(option).toHaveCSS('background-color', 'rgb(18, 52, 86)'); - await page.getByRole('dialog').getByRole('searchbox').press('Escape'); - await page.keyboard.press('Escape'); - await expect(page.locator('.altertable-combobox-popover')).toHaveCount(0); - const dates = page.getByRole('button', { - name: 'Choose dates Reporting dates, compared with previous period', - exact: true, - }); - await dates.press('Enter'); - await expect(page.locator('.altertable-date-range-popover')).toBeVisible(); - const preset = page.locator('.altertable-date-range-preset').first(); - await preset.click(); - await dates.press('Enter'); - await expect(preset).toHaveAttribute('aria-current', 'true'); - await expect(preset).toHaveCSS('background-color', 'rgb(18, 52, 86)'); -}); diff --git a/browser-tests/styles.spec.ts b/browser-tests/styles.spec.ts deleted file mode 100644 index 7b6663f7..00000000 --- a/browser-tests/styles.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('styles require explicit injection, respect CSP and install once per document', async ({ - page, -}) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - await page.goto('/styles'); - const styles = page.locator('style[data-altertable-styles]'); - await expect(styles).toHaveCount(0); - await expect(page.locator('.altertable-grid')).toHaveCSS('display', 'block'); - await page.getByRole('button', { name: 'Inject styles' }).click(); - await expect(styles).toHaveCount(1); - expect( - await styles.evaluate(element => (element as HTMLStyleElement).nonce) - ).toBe('styles-test'); - await expect(page.locator('.altertable-grid')).toHaveCSS('display', 'grid'); - await page.getByRole('button', { name: 'Inject styles' }).click(); - await expect(styles).toHaveCount(1); - await page.getByRole('button', { name: 'Style iframe' }).click(); - await expect( - page.frameLocator('iframe').locator('style[data-altertable-styles]') - ).toHaveCount(1); - await expect(styles).toHaveCount(1); - expect(errors).toEqual([]); -}); diff --git a/bun.lock b/bun.lock index eb12e307..23e6108e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,6 @@ "react-aria-components": "1.21.1", }, "devDependencies": { - "@playwright/test": "1.63.0", "@types/bun": "1.4.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -23,6 +22,7 @@ "oxfmt": "0.70.0", "oxlint": "1.85.0", "oxlint-tsgolint": "7.0.2003", + "playwright": "1.63.0", "react": "19.3.0", "react-dom": "19.3.0", "remark-cli": "12.0.1", @@ -31,6 +31,7 @@ "remark-validate-links": "13.1.0", "testcontainers": "12.2.0", "typescript": "7.0.2", + "vitest": "4.1.11", }, "peerDependencies": { "react": ">=19.2.0", @@ -86,6 +87,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], @@ -110,6 +113,8 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], @@ -200,8 +205,6 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -222,10 +225,44 @@ "@react-types/shared": ["@react-types/shared@3.36.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@sindresorhus/is": ["@sindresorhus/is@5.6.0", "", {}, "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], @@ -236,14 +273,20 @@ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], "@types/dockerode": ["@types/dockerode@4.0.1", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "@types/hosted-git-info": ["@types/hosted-git-info@3.0.5", "", {}, "sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg=="], @@ -316,6 +359,20 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -338,6 +395,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], @@ -392,6 +451,8 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -430,6 +491,8 @@ "convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], @@ -458,6 +521,8 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], @@ -484,16 +549,22 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], @@ -504,6 +575,8 @@ "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -648,6 +721,30 @@ "levenshtein-edit-distance": ["levenshtein-edit-distance@3.0.1", "", { "bin": { "levenshtein-edit-distance": "cli.js" } }, "sha512-/qMCkZbrAF7jZP/voqlkfNrBtEn0TMdhCK7OEBh/zb39t/c3wCnTjwU1ZvrMfQ3OxB8sBQXIpWRMM6FiQJVG3g=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], "linkify-it": ["linkify-it@6.1.0", "", { "dependencies": { "uc.micro": "^3.0.0" } }, "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw=="], @@ -668,6 +765,8 @@ "lucide-react": ["lucide-react@1.48.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-it": ["markdown-it@15.0.1", "", { "dependencies": { "argparse": "^3.0.0", "entities": "^8.0.0", "linkify-it": "^6.0.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g=="], @@ -786,6 +885,8 @@ "nan": ["nan@2.29.0", "", {}, "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], @@ -804,6 +905,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], @@ -838,14 +941,18 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="], + "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], @@ -918,6 +1025,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -932,12 +1041,16 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], @@ -954,6 +1067,10 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + "streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="], "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], @@ -986,8 +1103,16 @@ "time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1056,12 +1181,18 @@ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="], + "vite": ["vite@8.3.3", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1092,6 +1223,8 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -1116,6 +1249,8 @@ "markdown-it/argparse": ["argparse@3.0.2", "", {}, "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1124,6 +1259,8 @@ "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "ssh-remote-port-forward/@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/browser-tests/fixtures/annotation-drafts.ts b/dev/fixtures/annotation-drafts.ts similarity index 98% rename from browser-tests/fixtures/annotation-drafts.ts rename to dev/fixtures/annotation-drafts.ts index dcc441fb..71c534c9 100644 --- a/browser-tests/fixtures/annotation-drafts.ts +++ b/dev/fixtures/annotation-drafts.ts @@ -3,7 +3,7 @@ import { parseDataAppAnnotationDraft, type DataAppAnnotationDraft, } from '@/src/core/annotations'; -import type { AnnotationDraftSnapshot } from '@/browser-tests/fixtures/annotation-storage'; +import type { AnnotationDraftSnapshot } from '@/dev/fixtures/annotation-storage'; export function createAnnotationDraftStore( sourceVersion: string, diff --git a/browser-tests/fixtures/annotation-state-app.tsx b/dev/fixtures/annotation-state-app.tsx similarity index 100% rename from browser-tests/fixtures/annotation-state-app.tsx rename to dev/fixtures/annotation-state-app.tsx diff --git a/browser-tests/fixtures/annotation-storage.ts b/dev/fixtures/annotation-storage.ts similarity index 100% rename from browser-tests/fixtures/annotation-storage.ts rename to dev/fixtures/annotation-storage.ts diff --git a/browser-tests/fixtures/appearance.html b/dev/fixtures/appearance.html similarity index 100% rename from browser-tests/fixtures/appearance.html rename to dev/fixtures/appearance.html diff --git a/browser-tests/fixtures/appearance.ts b/dev/fixtures/appearance.ts similarity index 100% rename from browser-tests/fixtures/appearance.ts rename to dev/fixtures/appearance.ts diff --git a/browser-tests/fixtures/bridge-frame.html b/dev/fixtures/bridge-frame.html similarity index 100% rename from browser-tests/fixtures/bridge-frame.html rename to dev/fixtures/bridge-frame.html diff --git a/browser-tests/fixtures/bridge-frame.ts b/dev/fixtures/bridge-frame.ts similarity index 94% rename from browser-tests/fixtures/bridge-frame.ts rename to dev/fixtures/bridge-frame.ts index 68c59acf..d7332558 100644 --- a/browser-tests/fixtures/bridge-frame.ts +++ b/dev/fixtures/bridge-frame.ts @@ -1,4 +1,4 @@ -import { bridgeRoutes } from '@/browser-tests/fixtures/bridge-routes'; +import { bridgeRoutes } from '@/dev/fixtures/bridge-routes'; import { createMessageClient, createIframeTransport, diff --git a/browser-tests/fixtures/bridge-host.html b/dev/fixtures/bridge-host.html similarity index 100% rename from browser-tests/fixtures/bridge-host.html rename to dev/fixtures/bridge-host.html diff --git a/browser-tests/fixtures/bridge-host.tsx b/dev/fixtures/bridge-host.tsx similarity index 96% rename from browser-tests/fixtures/bridge-host.tsx rename to dev/fixtures/bridge-host.tsx index 7736ee2e..1e351e07 100644 --- a/browser-tests/fixtures/bridge-host.tsx +++ b/dev/fixtures/bridge-host.tsx @@ -6,9 +6,9 @@ import { createNavigationHandler, type DataAppLogger, } from '@altertable/data-app/embed'; -import { bridgeRoutes } from '@/browser-tests/fixtures/bridge-routes'; +import { bridgeRoutes } from '@/dev/fixtures/bridge-routes'; import { DataAppBridge } from '@altertable/data-app/react/embed'; -import '@/browser-tests/fixtures/dev-reload'; +import '@/dev/fixtures/dev-reload'; function trackNavigation( input: Parameters>[0] diff --git a/browser-tests/fixtures/bridge-routes.ts b/dev/fixtures/bridge-routes.ts similarity index 100% rename from browser-tests/fixtures/bridge-routes.ts rename to dev/fixtures/bridge-routes.ts diff --git a/browser-tests/fixtures/bundle-app.tsx b/dev/fixtures/bundle-app.tsx similarity index 98% rename from browser-tests/fixtures/bundle-app.tsx rename to dev/fixtures/bundle-app.tsx index ad358319..81680300 100644 --- a/browser-tests/fixtures/bundle-app.tsx +++ b/dev/fixtures/bundle-app.tsx @@ -17,7 +17,7 @@ import { } from '@altertable/data-app/react/ui'; import { AnnotationTarget } from '@altertable/data-app/react'; import { mountDataApp, textVariable } from '@altertable/data-app/react'; -import { bridgeRoutes } from '@/browser-tests/fixtures/bridge-routes'; +import { bridgeRoutes } from '@/dev/fixtures/bridge-routes'; const config: DataAppConfig = { title: 'Embedded report', scope: { organization: 'test', environment: 'prod' }, diff --git a/browser-tests/fixtures/bundle-host.html b/dev/fixtures/bundle-host.html similarity index 100% rename from browser-tests/fixtures/bundle-host.html rename to dev/fixtures/bundle-host.html diff --git a/browser-tests/fixtures/bundle-host.tsx b/dev/fixtures/bundle-host.tsx similarity index 98% rename from browser-tests/fixtures/bundle-host.tsx rename to dev/fixtures/bundle-host.tsx index 8d89f342..b1f3f288 100644 --- a/browser-tests/fixtures/bundle-host.tsx +++ b/dev/fixtures/bundle-host.tsx @@ -1,6 +1,6 @@ import { AppIcon } from '@altertable/data-app/react/ui'; import { Kbd } from '@/src/react/ui/Kbd'; -import { useAnnotationHost } from '@/browser-tests/fixtures/use-annotation-host'; +import { useAnnotationHost } from '@/dev/fixtures/use-annotation-host'; import type { Theme } from '@altertable/data-app/appearance'; import { StrictMode, useReducer, useRef, useState } from 'react'; import { Moon, Sun, PanelsTopLeft, AppWindow } from 'lucide-react'; @@ -37,8 +37,8 @@ import { createNavigationHandler, createSqlQueryHandler, } from '@altertable/data-app/embed'; -import { bridgeRoutes } from '@/browser-tests/fixtures/bridge-routes'; -import '@/browser-tests/fixtures/dev-reload'; +import { bridgeRoutes } from '@/dev/fixtures/bridge-routes'; +import '@/dev/fixtures/dev-reload'; import '@/dev/playground-host.css'; injectDataAppAnnotationStyles(); const hostOptions = new URLSearchParams(location.search); diff --git a/browser-tests/fixtures/client-cache.tsx b/dev/fixtures/client-cache.tsx similarity index 100% rename from browser-tests/fixtures/client-cache.tsx rename to dev/fixtures/client-cache.tsx diff --git a/browser-tests/fixtures/declared-app.tsx b/dev/fixtures/declared-app.tsx similarity index 100% rename from browser-tests/fixtures/declared-app.tsx rename to dev/fixtures/declared-app.tsx diff --git a/browser-tests/fixtures/dev-reload.ts b/dev/fixtures/dev-reload.ts similarity index 100% rename from browser-tests/fixtures/dev-reload.ts rename to dev/fixtures/dev-reload.ts diff --git a/browser-tests/fixtures/gallery-basics.tsx b/dev/fixtures/gallery-basics.tsx similarity index 100% rename from browser-tests/fixtures/gallery-basics.tsx rename to dev/fixtures/gallery-basics.tsx diff --git a/browser-tests/fixtures/gallery-cases.tsx b/dev/fixtures/gallery-cases.tsx similarity index 99% rename from browser-tests/fixtures/gallery-cases.tsx rename to dev/fixtures/gallery-cases.tsx index 27a63c46..134eaa06 100644 --- a/browser-tests/fixtures/gallery-cases.tsx +++ b/dev/fixtures/gallery-cases.tsx @@ -1,7 +1,7 @@ import { gallerySections, type GalleryCategory, -} from '@/browser-tests/fixtures/gallery-catalog'; +} from '@/dev/fixtures/gallery-catalog'; import { dimensionFilter, type DimensionSelection, @@ -77,7 +77,7 @@ import { } from '@altertable/data-app/react/ui'; import { DataSectionBoundary as DataSection } from '@/src/react/ui/DataSectionBoundary'; import { type DataView } from '@/src/core/data-view'; -import '@/browser-tests/fixtures/gallery.css'; +import '@/dev/fixtures/gallery.css'; const dimension = dimensionFilter({ key: 'fixture_dimension', diff --git a/browser-tests/fixtures/gallery-catalog.ts b/dev/fixtures/gallery-catalog.ts similarity index 100% rename from browser-tests/fixtures/gallery-catalog.ts rename to dev/fixtures/gallery-catalog.ts diff --git a/browser-tests/fixtures/gallery.css b/dev/fixtures/gallery.css similarity index 100% rename from browser-tests/fixtures/gallery.css rename to dev/fixtures/gallery.css diff --git a/browser-tests/fixtures/gallery.html b/dev/fixtures/gallery.html similarity index 100% rename from browser-tests/fixtures/gallery.html rename to dev/fixtures/gallery.html diff --git a/browser-tests/fixtures/gallery.tsx b/dev/fixtures/gallery.tsx similarity index 99% rename from browser-tests/fixtures/gallery.tsx rename to dev/fixtures/gallery.tsx index d36309e8..dbc845d3 100644 --- a/browser-tests/fixtures/gallery.tsx +++ b/dev/fixtures/gallery.tsx @@ -1,9 +1,9 @@ -import { GalleryBasics } from '@/browser-tests/fixtures/gallery-basics'; -import { GalleryCases } from '@/browser-tests/fixtures/gallery-cases'; +import { GalleryBasics } from '@/dev/fixtures/gallery-basics'; +import { GalleryCases } from '@/dev/fixtures/gallery-cases'; import { galleryCategories, gallerySections, -} from '@/browser-tests/fixtures/gallery-catalog'; +} from '@/dev/fixtures/gallery-catalog'; import { useEffect, useState } from 'react'; import type { DataAppConfig } from '@altertable/data-app/config'; import { getDataAppNavigation } from '@altertable/data-app/client'; diff --git a/browser-tests/fixtures/hooks-app.html b/dev/fixtures/hooks-app.html similarity index 100% rename from browser-tests/fixtures/hooks-app.html rename to dev/fixtures/hooks-app.html diff --git a/browser-tests/fixtures/hooks-app.tsx b/dev/fixtures/hooks-app.tsx similarity index 97% rename from browser-tests/fixtures/hooks-app.tsx rename to dev/fixtures/hooks-app.tsx index 90d05368..18662af3 100644 --- a/browser-tests/fixtures/hooks-app.tsx +++ b/dev/fixtures/hooks-app.tsx @@ -1,10 +1,10 @@ import { createDataContext as registerContext } from '@/src/react/ui/data-context'; import { useDeclaredResult } from '@/src/react/view-runtime'; -import { DeclaredApp } from '@/browser-tests/fixtures/declared-app'; +import { DeclaredApp } from '@/dev/fixtures/declared-app'; import { DataSectionBoundary as DataSection } from '@/src/react/ui/DataSectionBoundary'; import { bindDataset } from '@/src/react/bindings'; import { defineDataContent } from '@/src/react/content'; -import { ClientCacheApp } from '@/browser-tests/fixtures/client-cache'; +import { ClientCacheApp } from '@/dev/fixtures/client-cache'; import { useState } from 'react'; import { createRoot } from 'react-dom/client'; import { injectDataAppStyles } from '@altertable/data-app/react'; diff --git a/browser-tests/fixtures/inspection-app.html b/dev/fixtures/inspection-app.html similarity index 100% rename from browser-tests/fixtures/inspection-app.html rename to dev/fixtures/inspection-app.html diff --git a/browser-tests/fixtures/inspection-app.tsx b/dev/fixtures/inspection-app.tsx similarity index 100% rename from browser-tests/fixtures/inspection-app.tsx rename to dev/fixtures/inspection-app.tsx diff --git a/browser-tests/fixtures/layout-host.html b/dev/fixtures/layout-host.html similarity index 100% rename from browser-tests/fixtures/layout-host.html rename to dev/fixtures/layout-host.html diff --git a/browser-tests/fixtures/layout.html b/dev/fixtures/layout.html similarity index 100% rename from browser-tests/fixtures/layout.html rename to dev/fixtures/layout.html diff --git a/browser-tests/fixtures/layout.tsx b/dev/fixtures/layout.tsx similarity index 100% rename from browser-tests/fixtures/layout.tsx rename to dev/fixtures/layout.tsx diff --git a/browser-tests/fixtures/skeleton.html b/dev/fixtures/skeleton.html similarity index 100% rename from browser-tests/fixtures/skeleton.html rename to dev/fixtures/skeleton.html diff --git a/browser-tests/fixtures/skeleton.tsx b/dev/fixtures/skeleton.tsx similarity index 100% rename from browser-tests/fixtures/skeleton.tsx rename to dev/fixtures/skeleton.tsx diff --git a/browser-tests/fixtures/style-stress.html b/dev/fixtures/style-stress.html similarity index 100% rename from browser-tests/fixtures/style-stress.html rename to dev/fixtures/style-stress.html diff --git a/browser-tests/fixtures/style-stress.tsx b/dev/fixtures/style-stress.tsx similarity index 100% rename from browser-tests/fixtures/style-stress.tsx rename to dev/fixtures/style-stress.tsx diff --git a/browser-tests/fixtures/styles.html b/dev/fixtures/styles.html similarity index 100% rename from browser-tests/fixtures/styles.html rename to dev/fixtures/styles.html diff --git a/browser-tests/fixtures/styles.ts b/dev/fixtures/styles.ts similarity index 100% rename from browser-tests/fixtures/styles.ts rename to dev/fixtures/styles.ts diff --git a/browser-tests/fixtures/use-annotation-host.ts b/dev/fixtures/use-annotation-host.ts similarity index 97% rename from browser-tests/fixtures/use-annotation-host.ts rename to dev/fixtures/use-annotation-host.ts index 97c69d5a..6d0b79af 100644 --- a/browser-tests/fixtures/use-annotation-host.ts +++ b/dev/fixtures/use-annotation-host.ts @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { injectDataAppAnnotationStyles } from '@altertable/data-app/react'; -import { useAnnotationDrafts } from '@/browser-tests/fixtures/useAnnotationDrafts'; +import { useAnnotationDrafts } from '@/dev/fixtures/useAnnotationDrafts'; import { MessageRoutingError, type DataAppAnnotationDraft, diff --git a/browser-tests/fixtures/useAnnotationDrafts.ts b/dev/fixtures/useAnnotationDrafts.ts similarity index 91% rename from browser-tests/fixtures/useAnnotationDrafts.ts rename to dev/fixtures/useAnnotationDrafts.ts index e2ab7a0f..0d218cbc 100644 --- a/browser-tests/fixtures/useAnnotationDrafts.ts +++ b/dev/fixtures/useAnnotationDrafts.ts @@ -2,8 +2,8 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react'; import { loadAnnotationDrafts, saveAnnotationDrafts, -} from '@/browser-tests/fixtures/annotation-storage'; -import { createAnnotationDraftStore } from '@/browser-tests/fixtures/annotation-drafts'; +} from '@/dev/fixtures/annotation-storage'; +import { createAnnotationDraftStore } from '@/dev/fixtures/annotation-drafts'; /** Host collection is independent of agent submission. Supply an account/app-scoped key for recovery. */ type AnnotationDraftsOptions = { diff --git a/browser-tests/server.ts b/dev/server.ts similarity index 93% rename from browser-tests/server.ts rename to dev/server.ts index 59ff447e..53dac7a8 100644 --- a/browser-tests/server.ts +++ b/dev/server.ts @@ -5,18 +5,18 @@ import starterConfig from '@/examples/starter-local-data-app/app'; import { Database } from 'bun:sqlite'; import { watch } from 'node:fs'; import { parseDataAppAnnotationDraft } from '@altertable/data-app/contract'; -import skeleton from '@/browser-tests/fixtures/skeleton.html'; -import hooksApp from '@/browser-tests/fixtures/hooks-app.html'; -import inspectionApp from '@/browser-tests/fixtures/inspection-app.html'; -import gallery from '@/browser-tests/fixtures/gallery.html'; -import styles from '@/browser-tests/fixtures/styles.html'; -import appearance from '@/browser-tests/fixtures/appearance.html'; -import styleStress from '@/browser-tests/fixtures/style-stress.html'; -import layout from '@/browser-tests/fixtures/layout.html'; -import layoutHost from '@/browser-tests/fixtures/layout-host.html'; -import bundleHost from '@/browser-tests/fixtures/bundle-host.html'; -import bridgeHost from '@/browser-tests/fixtures/bridge-host.html'; -import bridgeFrame from '@/browser-tests/fixtures/bridge-frame.html'; +import skeleton from '@/dev/fixtures/skeleton.html'; +import hooksApp from '@/dev/fixtures/hooks-app.html'; +import inspectionApp from '@/dev/fixtures/inspection-app.html'; +import gallery from '@/dev/fixtures/gallery.html'; +import styles from '@/dev/fixtures/styles.html'; +import appearance from '@/dev/fixtures/appearance.html'; +import styleStress from '@/dev/fixtures/style-stress.html'; +import layout from '@/dev/fixtures/layout.html'; +import layoutHost from '@/dev/fixtures/layout-host.html'; +import bundleHost from '@/dev/fixtures/bundle-host.html'; +import bridgeHost from '@/dev/fixtures/bridge-host.html'; +import bridgeFrame from '@/dev/fixtures/bridge-frame.html'; async function bundle(entry: string) { const result = await Bun.build({ diff --git a/package.json b/package.json index 62efb1c4..d23a8fd5 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,15 @@ "build": "bun run scripts/build.ts && tsc -p tsconfig.build.json && bun run scripts/build-declarations.ts", "dev": "bun run scripts/dev.ts", "typecheck": "bun install --frozen-lockfile && tsc --noEmit", - "lint": "oxlint --type-aware src tests scripts browser-tests dev examples/starter-data-app", + "lint": "oxlint --type-aware src tests scripts dev examples/starter-data-app vitest.config.ts", "format": "oxfmt .", "format:check": "oxfmt --check .", - "test": "bun test ./tests", - "check": "bun run build && bun run typecheck && bun run lint && bun run check:styles && bun run lint:md && bun run check:links && bun run format:check && bun run test && bun run test:package && bun run test:starter", - "test:package": "bun run scripts/check-package.ts", + "test": "vitest run", + "check": "bun run build && bun run typecheck && bun run lint && bun run check:styles && bun run lint:md && bun run check:links && bun run format:check && bun run test && bun run check:package && bun run check:starter", + "check:package": "bun run scripts/check-package.ts", "check:workflows": "bash scripts/check-workflows.sh", - "test:browser": "playwright test --config browser-tests/playwright.config.ts", - "lint:fix": "oxlint --type-aware --fix src tests scripts browser-tests", - "test:starter": "bun run scripts/check-starter.ts", + "lint:fix": "oxlint --type-aware --fix src tests scripts dev vitest.config.ts", + "check:starter": "bun run scripts/check-starter.ts", "lint:md": "markdownlint-cli2", "check:links": "remark . --ext md --rc-path .remarkrc.json --frail --no-stdout", "check:links:external": "remark . --ext md --rc-path .remarkrc-external.json --frail --no-stdout", @@ -118,7 +117,6 @@ "react-aria-components": "1.21.1" }, "devDependencies": { - "@playwright/test": "1.63.0", "@types/bun": "1.4.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -126,6 +124,7 @@ "oxfmt": "0.70.0", "oxlint": "1.85.0", "oxlint-tsgolint": "7.0.2003", + "playwright": "1.63.0", "react": "19.3.0", "react-dom": "19.3.0", "remark-cli": "12.0.1", @@ -133,7 +132,8 @@ "remark-lint-no-dead-urls": "2.0.1", "remark-validate-links": "13.1.0", "testcontainers": "12.2.0", - "typescript": "7.0.2" + "typescript": "7.0.2", + "vitest": "4.1.11" }, "peerDependencies": { "react": ">=19.2.0", diff --git a/scripts/check-app-styles.ts b/scripts/check-app-styles.ts index 2eedb20e..9bed52ff 100644 --- a/scripts/check-app-styles.ts +++ b/scripts/check-app-styles.ts @@ -1,10 +1,12 @@ import { Glob } from 'bun'; import { validateAuthoredStyles } from '@/src/react/style-validation'; +await import('@/scripts/check-style-integrity'); + const requested = process.argv.slice(2); const paths = requested.length ? requested - : ['examples/**/*.{tsx,css,html}', 'dev/**/*.{tsx,css,html}']; + : ['examples/**/*.{tsx,css,html}', 'dev/*.{tsx,css,html}']; let failed = false; for (const pattern of paths) { let matched = false; diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 360f5620..8109d76b 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -100,6 +100,34 @@ try { packageDirectory, '--strip-components=1', ]); + await writeFile( + join(temporary, 'package.json'), + JSON.stringify({ name: 'packed-data-app-consumer', type: 'module' }) + ); + const consumerTests = join(temporary, 'tests/public-api'); + await mkdir(consumerTests, { recursive: true }); + for (const name of [ + 'operations.test.ts', + 'messages.test.ts', + 'worker.test.ts', + ]) { + await writeFile( + join(consumerTests, name), + await readFile(join(root, 'tests/public-api', name), 'utf8') + ); + } + await run( + [ + 'node', + join(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--root', + temporary, + '--config', + join(root, 'vitest.config.ts'), + ], + temporary + ); for (const path of paths) { if (path.endsWith('.d.ts')) { const declaration = await readFile(join(packageDirectory, path), 'utf8'); @@ -143,35 +171,6 @@ export const api = { createDataClient, Grid, DataAppSkeleton, defineDateRangeCon join(temporary, 'embed.tsx'), `import { DataAppBridge } from "@altertable/data-app/react/embed"; export { DataAppBridge }; -` - ); - await writeFile( - join(temporary, 'bridge.ts'), - `import { attachDataAppBridge } from "@altertable/data-app/embed"; -import { createMessageRouter, defineMessageRoute, MessageRoutingError } from "@altertable/data-app/contract"; -const events = new EventTarget(); -const sent = []; -const target = { postMessage(message) { sent.push(message); } }; -const iframe = Object.assign(new EventTarget(), { contentWindow: target }); -const host = Object.assign(events, { location: { search: "", hash: "" } }); -const router = createMessageRouter({ "test:denied": defineMessageRoute({ input() { return null; }, output() { return null; } }) }, { - "test:denied"() { throw new MessageRoutingError("forbidden", "Denied", "request"); } -}); -const bridgeHost = attachDataAppBridge({ iframe, window: host, connection: { type: "origin", origin: "https://app.example" }, onMessage: router.dispatch }); -function receive(message) { - events.dispatchEvent(Object.assign(new Event("message"), { - source: target, origin: "https://app.example", data: { channel: "altertable:data-app", version: 1, documentId: "document", ...message } - })); -} -try { - receive({ type: "bridge:ready" }); - const sessionId = sent.at(-1).sessionId; - receive({ type: "bridge:request", sessionId, id: "call", route: "test:denied", payload: null }); - await new Promise(resolve => setTimeout(resolve, 0)); - const response = sent.at(-1); - if (response.code !== "forbidden" || response.message !== "Denied" || response.requestId !== "request") - throw new Error("Public routed errors lost identity across packed entries"); -} finally { bridgeHost.dispose(); } ` ); await writeFile( @@ -208,62 +207,14 @@ if (typeof createDataHandler !== "function" || typeof localLakehouse !== "functi join(temporary, 'tsconfig.json'), ]); await run(['bun', join(temporary, 'server.ts')], temporary); - await run(['bun', join(temporary, 'bridge.ts')], temporary); - await writeFile( - join(temporary, 'worker-asset.mjs'), - `import { readFile } from 'node:fs/promises'; -import { runInNewContext } from 'node:vm'; -import { strict as assert } from 'node:assert'; - -const asset = await readFile(new URL(import.meta.resolve('@altertable/data-app/worker')), 'utf8'); -assert(!/^import\\s|\\bimport\\s*\\(/m.test(asset), 'Worker must have no runtime imports'); -assert(!/react-query|react-dom|attachNavigation|createAppLocation|Bun\\.|process\\./.test(asset)); -// Simulate loading the one uploaded ESM module; consumers only resolve/read it. -const { default: worker } = await import('data:text/javascript;base64,' + Buffer.from(asset).toString('base64')); -const response = await worker.fetch(new Request('https://test-report-app-1.example.test/'), { - DOMAIN_NAME: 'example.test', PARENT_ORIGINS: 'https://host.example', -}); -assert.equal(response.status, 200); -const html = await response.text(); -assert(html.includes('data-parent-origin="https://host.example"')); -const source = html.match(/]*>([\\s\\S]*?)<\\/script\\b[^>]*>/i)[1]; -assert(!/createAppLocation|attachNavigation|replaceState|pushState|navigation:update/.test(source)); - -function execute(parentOrigin) { - const sent = []; - const listeners = new Map(); - const parent = { postMessage(message, origin) { sent.push({ message, origin }); } }; - const frame = { - parent, - addEventListener(type, listener) { listeners.set(type, listener); }, - removeEventListener(type) { listeners.delete(type); }, - }; - runInNewContext(source, { - document: { currentScript: { dataset: { parentOrigin } } }, - window: frame, - URL, - crypto: { randomUUID() { return 'document'; } }, - }); - return { sent, listeners, parent, frame }; -} - -assert.throws(() => execute(undefined), /Missing trusted parent origin/); -assert.throws(() => execute('https://host.example/path'), /Expected an exact parent origin/); -const { sent, listeners, parent } = execute('https://host.example'); -const message = { channel: 'altertable:data-app', version: 1, type: 'bridge:connect', documentId: 'host', token: 'token' }; -listeners.get('message')({ origin: 'https://other.example', source: parent, data: message }); -assert.equal(sent.length, 0); -listeners.get('message')({ origin: 'https://host.example', source: {}, data: message }); -assert.equal(sent.length, 0); -listeners.get('message')({ origin: 'https://host.example', source: parent, data: message }); -assert.equal(sent.length, 1); -assert.equal(sent[0].origin, 'https://host.example'); -assert.equal(sent[0].message.type, 'bridge:ready'); -assert.equal(sent[0].message.token, 'token'); -assert.equal(sent[0].message.documentId, 'document'); -` + const workerAsset = await readFile( + join(packageDirectory, 'dist/worker.js'), + 'utf8' ); - await run(['node', join(temporary, 'worker-asset.mjs')], temporary); + if (/^import\s|\bimport\s*\(/m.test(workerAsset)) + throw new Error('Worker must have no runtime imports.'); + if (/react-query|react-dom|Bun\.|process\./.test(workerAsset)) + throw new Error('Worker contains unsupported runtime dependencies.'); await run( [ 'node', diff --git a/scripts/check-style-integrity.ts b/scripts/check-style-integrity.ts new file mode 100644 index 00000000..de709f1d --- /dev/null +++ b/scripts/check-style-integrity.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { Glob } from 'bun'; +import { + dataAppStyleClasses, + dataAppStyleTokens, +} from '@/src/react/style-contract'; +const files = await Array.fromAsync(new Glob('src/react/**/*.css').scan('.')); +const sources = await Promise.all(files.map(path => Bun.file(path).text())); +const styles = sources.join('\n'); +const definitions = new Set( + sources.flatMap(source => + [...source.matchAll(/(--atbl-[\w-]+)\s*:/g)].map(match => match[1]) + ) +); +for (const [index, source] of sources.entries()) { + for (const match of source.matchAll(/var\(\s*(--atbl-[\w-]+)\s*(,)?/g)) + assert( + match[1]!.startsWith('--atbl-input-') || + definitions.has(match[1]) || + match[2], + `${files[index]}: unresolved token ${match[1]}` + ); + if (files[index]!.startsWith('src/react/ui/')) + assert( + !/box-shadow:[^;]*rgb\(/.test(source), + `${files[index]}: hardcoded shadow color` + ); +} +for (const [name, hook] of Object.entries(dataAppStyleClasses)) { + const source = await Bun.file( + hook.kind === 'root' + ? `src/react/ui/${hook.component}.tsx` + : 'src/react/base.css' + ).text(); + assert( + new RegExp(String.raw`(?]*?)>/g; +for await (const path of new Glob('src/react/{ui,annotations}/*.tsx').scan( + '.' +)) { + const source = await Bun.file(path).text(); + for (const [, tag, attributes] of source.matchAll(controls)) { + assert( + attributes?.includes('data-atbl-focus'), + `${path}: ${tag} lacks a focus hook` + ); + assert( + tag === 'AriaTabPanel' || attributes?.includes('data-atbl-control'), + `${path}: ${tag} lacks a cursor hook` + ); + } +} diff --git a/scripts/dev.ts b/scripts/dev.ts index a9540e2d..dbc6a7d7 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -95,7 +95,7 @@ if (!initialBuildSucceeded) { } function startServer() { - return Bun.spawn([process.execPath, 'browser-tests/server.ts'], { + return Bun.spawn([process.execPath, 'dev/server.ts'], { cwd: root, env: { ...process.env, diff --git a/scripts/install-test-browser.sh b/scripts/install-test-browser.sh index 26db345f..eeb085ef 100644 --- a/scripts/install-test-browser.sh +++ b/scripts/install-test-browser.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -bun run --bun playwright install --with-deps chromium +bunx --no-install playwright install --with-deps chromium diff --git a/tests/annotation-drafts.test.ts b/tests/annotation-drafts.test.ts deleted file mode 100644 index 054f912e..00000000 --- a/tests/annotation-drafts.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { expect, test } from 'bun:test'; -import { createAnnotationDraftStore } from '@/browser-tests/fixtures/annotation-drafts'; -import type { DataAppAnnotationDraft } from '@/src/core/annotations'; - -const draft: DataAppAnnotationDraft = { - id: 'one', - target: { - id: 'revenue', - label: 'Revenue', - kind: 'widget', - text: '42', - queryNames: [], - glossaryIds: [], - }, - comment: 'Compare last year', - context: { - search: '', - hash: '', - viewport: { width: 600, height: 400 }, - rect: { x: 0, y: 0, width: 200, height: 100 }, - }, -}; - -test('host collection acknowledges only unchanged submitted snapshots and supports deletion undo', () => { - const store = createAnnotationDraftStore('version-1'); - store.addAnnotation(draft); - store.addAnnotation({ ...draft, comment: 'Retried request' }); - expect(store.getSnapshot().drafts[0].comment).toBe(draft.comment); - store.updateAnnotation(draft.id, 'New edit'); - store.acknowledgeSubmission([draft]); - expect(store.getSnapshot().drafts).toHaveLength(1); - store.deleteAnnotation(draft.id); - expect(store.getSnapshot().drafts).toHaveLength(0); - store.undoDelete(draft.id); - expect(store.getSnapshot().drafts[0].comment).toBe('New edit'); - store.acknowledgeSubmission(store.getSnapshot().drafts); - expect(store.getSnapshot().drafts).toHaveLength(0); -}); - -test('restore calls share hydration and discard older app-version batches', async () => { - let loads = 0; - let resolve!: (value: { - sourceVersion: string; - drafts: DataAppAnnotationDraft[]; - }) => void; - const deferred = new Promise<{ - sourceVersion: string; - drafts: DataAppAnnotationDraft[]; - }>(done => { - resolve = done; - }); - const saved: { sourceVersion: string; drafts: DataAppAnnotationDraft[] }[] = - []; - const store = createAnnotationDraftStore('version-2', { - load: () => { - loads++; - return deferred; - }, - save: async snapshot => { - saved.push(snapshot); - }, - }); - const first = store.restore(); - const second = store.restore(); - expect(loads).toBe(1); - expect(() => store.addAnnotation(draft)).toThrow('still restoring'); - resolve({ sourceVersion: 'version-1', drafts: [draft] }); - await Promise.all([first, second]); - expect(store.getSnapshot().drafts).toEqual([]); - expect(store.getSnapshot().deleted).toBeUndefined(); - expect(saved).toEqual([{ sourceVersion: 'version-2', drafts: [] }]); - store.addAnnotation({ ...draft, id: 'new' }); - expect(store.getSnapshot().drafts).toHaveLength(1); -}); - -test('local writes stay ordered, recover from storage failure, and expose durability', async () => { - const comments: string[] = []; - const store = createAnnotationDraftStore('version-1', { - load: async () => undefined, - save: async snapshot => { - comments.push(snapshot.drafts[0]?.comment ?? 'empty'); - if (comments.length === 1) throw new Error('Storage unavailable'); - }, - }); - await store.restore(); - store.addAnnotation(draft); - store.updateAnnotation(draft.id, 'Latest edit'); - expect(store.getSnapshot().persisting).toBe(true); - await store.flushPersistence(); - expect(comments).toEqual(['Compare last year', 'Latest edit']); - expect(store.getSnapshot().storageError).toBe(false); - expect(store.getSnapshot().persisting).toBe(false); - expect(store.getSnapshot().drafts[0].comment).toBe('Latest edit'); -}); - -test('an over-limit edit leaves the accepted collection and recovery snapshot unchanged', async () => { - let saved: - | { sourceVersion: string; drafts: DataAppAnnotationDraft[] } - | undefined; - const persistence = { - load: async () => saved, - save: async (snapshot: { - sourceVersion: string; - drafts: DataAppAnnotationDraft[]; - }) => { - saved = snapshot; - }, - }; - const store = createAnnotationDraftStore('version-1', persistence); - await store.restore(); - // Each individual draft is valid; the collection crosses its metadata budget only after edits. - for (let index = 0; index < 20; index++) { - store.addAnnotation({ - ...draft, - id: `draft-${index}`, - context: { - ...draft.context, - displayedInput: { detail: 'x'.repeat(4500) }, - }, - }); - } - await store.flushPersistence(); - for (let index = 0; index < 20; index++) { - const before = store.getSnapshot(); - try { - store.updateAnnotation(`draft-${index}`, 'x'.repeat(2000)); - } catch (error) { - expect(error).toHaveProperty('code', 'annotation_limit'); - expect(store.getSnapshot()).toBe(before); - await store.flushPersistence(); - expect(saved?.drafts).toEqual(before.drafts); - const recovered = createAnnotationDraftStore('version-1', persistence); - await recovered.restore(); - expect(recovered.getSnapshot().drafts).toEqual(before.drafts); - return; - } - } - throw new Error('Expected the edited collection to reach its limit'); -}); diff --git a/tests/annotation-editor-state.test.ts b/tests/annotation-editor-state.test.ts deleted file mode 100644 index f5b70b65..00000000 --- a/tests/annotation-editor-state.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - annotationControlsReducer, - createAnnotationControlsState, - type AnnotationEditorState, -} from '@/src/react/annotations/annotation-editor-state'; - -const editor: AnnotationEditorState = { - target: { - id: 'revenue', - label: 'Revenue', - kind: 'widget', - element: {} as HTMLElement, - }, - draft: { - id: 'capture-one', - target: { - id: 'revenue', - label: 'Revenue', - kind: 'widget', - text: '42', - queryNames: [], - glossaryIds: [], - }, - context: { - search: '', - hash: '', - viewport: { width: 600, height: 400 }, - rect: { x: 0, y: 0, width: 200, height: 100 }, - }, - comment: '', - }, - comment: 'Compare last year', - captureStatus: 'capturing', - error: '', - discardArmed: false, - shaking: false, -}; - -test('consecutive Escape actions discard only the dirty editor and keep annotation mode active', () => { - let state = annotationControlsReducer(createAnnotationControlsState(true), { - type: 'editorOpened', - editor, - }); - state = annotationControlsReducer(state, { - type: 'editorEscaped', - hasUnsavedChanges: true, - }); - expect(state.editor?.shaking).toBe(true); - expect(state.editor?.comment).toBe(editor.comment); - state = annotationControlsReducer(state, { - type: 'editorEscaped', - hasUnsavedChanges: true, - }); - expect(state.editor).toBeUndefined(); - expect(state.annotationModeActive).toBe(true); -}); - -test('editing after a discard prompt requires a new confirmation; saved comments close immediately', () => { - let state = annotationControlsReducer(createAnnotationControlsState(true), { - type: 'editorOpened', - editor, - }); - state = annotationControlsReducer(state, { - type: 'editorEscaped', - hasUnsavedChanges: true, - }); - state = annotationControlsReducer(state, { - type: 'commentChanged', - comment: 'Show weekly totals', - }); - state = annotationControlsReducer(state, { - type: 'editorEscaped', - hasUnsavedChanges: true, - }); - expect(state.editor?.comment).toBe('Show weekly totals'); - expect(state.editor?.shaking).toBe(true); - state = annotationControlsReducer(state, { - type: 'editorEscaped', - hasUnsavedChanges: false, - }); - expect(state.editor).toBeUndefined(); -}); - -test('a late screenshot result cannot change a newer selection or reopen a closed editor', () => { - let state = annotationControlsReducer(createAnnotationControlsState(true), { - type: 'editorOpened', - editor, - }); - state = annotationControlsReducer(state, { - type: 'editorOpened', - editor: { ...editor, draft: { ...editor.draft, id: 'capture-two' } }, - }); - state = annotationControlsReducer(state, { - type: 'captureCompleted', - draftId: 'capture-one', - succeeded: false, - }); - expect(state.editor?.captureStatus).toBe('capturing'); - state = annotationControlsReducer(state, { - type: 'externalModeObserved', - active: false, - }); - state = annotationControlsReducer(state, { - type: 'captureCompleted', - draftId: 'capture-two', - succeeded: true, - }); - expect(state.editor).toBeUndefined(); -}); diff --git a/tests/annotation-widgets.test.tsx b/tests/annotation-widgets.test.tsx deleted file mode 100644 index 63aa4628..00000000 --- a/tests/annotation-widgets.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import type { ReactNode } from 'react'; -import { - DataAppProvider, - MetricWidget, - TableWidget, - VisualizationWidget, -} from '@altertable/data-app/react/ui'; -import type { MetricDefinition } from '@altertable/data-app/react'; - -function renderWidget(children: ReactNode) { - return renderToStaticMarkup({children}); -} - -test('widget targets retain their evidence identity when loading becomes ready', () => { - const metric: MetricDefinition = { - id: 'revenue', - label: 'Revenue', - format: { kind: 'count' }, - evidence: { id: 'revenue', queryNames: ['counts'] }, - }; - for (const loading of [true, false]) { - const reading = loading - ? { loading: true as const } - : { loading: false as const, value: [{ id: 'row' }] }; - const metricMarkup = renderWidget( - - ); - expect(metricMarkup).toContain('data-annotation-id="revenue"'); - const visualMarkup = renderWidget( - rows.length === 0} - emptyFallback={{ title: 'No data' }} - > - {() =>

Trend values

} -
- ); - expect(visualMarkup).toContain('data-annotation-id="trend"'); - const tableMarkup = renderWidget( - row.id} - columns={[{ id: 'id', header: 'ID', cell: row => row.id }]} - emptyFallback={{ title: 'No rows' }} - /> - ); - expect(tableMarkup).toContain('data-annotation-id="rows"'); - } -}); diff --git a/tests/appearance.test.ts b/tests/appearance.test.ts deleted file mode 100644 index 3e482959..00000000 --- a/tests/appearance.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - normalizeAppearance, - parseAppearance, -} from '@altertable/data-app/appearance'; - -test('appearance defaults merge typed overrides and ignore unrelated parsed fields', () => { - const settings = normalizeAppearance({ typography: { heading: 'Georgia' } }); - expect(settings.typography).toEqual({ body: 'system', heading: 'Georgia' }); - settings.chartColors.length = 0; - expect(normalizeAppearance().chartColors.length).toBeGreaterThan(0); - expect( - parseAppearance({ - theme: 'dark', - future: true, - typography: { future: true }, - }).theme - ).toBe('dark'); - for (const value of [undefined, null, [], 'invalid', { theme: 'invalid' }]) - expect(parseAppearance(value)).toEqual(normalizeAppearance()); -}); - -test('invalid appearance fields fall back without discarding supported settings', () => { - const defaults = normalizeAppearance(); - expect( - parseAppearance({ - theme: 'dark', - baseColor: 'invalid', - accentColor: 'invalid', - darkAccentColor: 42, - chartColors: ['invalid'], - density: 'compact', - cornerRadius: 'invalid', - elevation: null, - typography: { body: ';invalid', heading: 'Georgia' }, - }) - ).toEqual({ - ...defaults, - theme: 'dark', - density: 'compact', - typography: { body: defaults.typography.body, heading: 'Georgia' }, - }); - expect(parseAppearance({ typography: null }).typography).toEqual( - defaults.typography - ); - for (const chartColors of [[], Array(9).fill('#112233'), null]) - expect(parseAppearance({ chartColors }).chartColors).toEqual( - defaults.chartColors - ); - expect( - parseAppearance({ accentColor: '#112233', darkAccentColor: 'invalid' }) - ).toMatchObject({ - accentColor: '#112233', - darkAccentColor: undefined, - }); -}); diff --git a/tests/authoring.test.ts b/tests/authoring.test.ts deleted file mode 100644 index 27ef2ebc..00000000 --- a/tests/authoring.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { expect, test } from 'bun:test'; -import { parseAppearance } from '@altertable/data-app/appearance'; -import { - connectionCheck, - defineDateRangeContract, - parseCount, - parseDateRangeInput, - parseEmptyInput, - parseLabel, - parseTrue, - previousDateRange, - rowsAsRecords, -} from '@altertable/data-app/contract'; -import { resolveDataView } from '@/src/core/data-view'; - -test('starter connection requires a successful bounded query', async () => { - const query = connectionCheck(); - const calls: unknown[] = []; - const result = await query.run( - { - signal: new AbortController().signal, - lakehouse: { - async queryAll(statement, options) { - calls.push({ statement, limit: options.limit, name: options.name }); - - return { columns: [{ name: 'connection_check' }], rows: [[1]] }; - }, - }, - }, - query.input({}) - ); - expect(result).toBe(true); - expect(calls).toEqual([ - { - statement: 'SELECT 1 AS connection_check', - limit: 1, - name: 'connection-check', - }, - ]); - expect(query.output(true)).toBe(true); - expect(() => parseTrue(false)).toThrow(); - expect(() => parseEmptyInput({ extra: true })).toThrow(); - expect(() => parseEmptyInput([])).toThrow(); - expect( - query.run( - { - signal: new AbortController().signal, - lakehouse: { - async queryAll() { - throw new Error('No lakehouse access'); - }, - }, - }, - {} - ) - ).rejects.toThrow('No lakehouse access'); -}); - -test('calendar inputs stay within valid dates and available coverage', () => { - const bounded = { - minDate: '2026-01-01', - maxDate: '2026-01-31', - maxRangeDays: 7, - }; - expect( - parseDateRangeInput({ start: '2026-01-02', end: '2026-01-08' }, bounded) - ).toEqual({ - start: '2026-01-02', - end: '2026-01-08', - }); - for (const range of [ - { start: '2026-02-30', end: '2026-02-30' }, - { start: '2026-01-08', end: '2026-01-02' }, - { start: '2026-01-01', end: '2026-01-08' }, - { start: '2025-12-31', end: '2026-01-02' }, - ]) { - expect(() => parseDateRangeInput(range, bounded)).toThrow(); - } -}); - -test('query result parsing rejects invalid metrics and malformed row shapes', () => { - expect(parseCount('12')).toBe(12); - for (const value of [-1, 1.5, 'not-a-number', Number.MAX_SAFE_INTEGER + 1]) { - expect(() => parseCount(value)).toThrow(); - } - expect(parseLabel('Catalog')).toBe('Catalog'); - expect(() => parseLabel('', 20)).toThrow(); - expect( - rowsAsRecords( - { columns: [{ name: 'name' }, { name: 'count' }], rows: [['A', 2]] }, - ['count'] - ) - ).toEqual([{ name: 'A', count: 2 }]); - expect(() => - rowsAsRecords({ columns: [{ name: 'name' }], rows: [['A']] }, ['count']) - ).toThrow(); - expect(() => - rowsAsRecords({ columns: [{ name: 'name' }], rows: [['A', 2]] }, ['name']) - ).toThrow(); -}); - -test('request state labels data from an older input and preserves it on failure', () => { - const options = { - requestedInput: { start: '2026-02-01' }, - previous: { input: { start: '2026-01-01' }, data: { count: 12 } }, - pending: true, - sameInput(left: { start: string }, right: { start: string }) { - return left.start === right.start; - }, - describe(input: { start: string }) { - return input.start; - }, - isEmpty() { - return false; - }, - }; - expect(resolveDataView(options)).toMatchObject({ - kind: 'updating', - displayedInput: { start: '2026-01-01' }, - requestedInput: { start: '2026-02-01' }, - }); - expect( - resolveDataView({ - ...options, - pending: false, - error: new Error('unavailable'), - }) - ).toMatchObject({ - kind: 'stale-error', - data: { count: 12 }, - }); -}); - -test('a changing date range keeps the displayed period with its result', () => { - const range = defineDateRangeContract({ - minDate: '2026-01-01', - maxDate: '2026-01-31', - maxRangeDays: 7, - timeZone: 'UTC', - }); - const displayed = { start: '2026-01-01', end: '2026-01-03' }; - const requested = { start: '2026-01-04', end: '2026-01-06' }; - const view = resolveDataView({ - requestedInput: requested, - previous: { input: displayed, data: { count: 4 } }, - pending: true, - sameInput(left, right) { - return left.start === right.start && left.end === right.end; - }, - describe: range.describeInput, - isEmpty() { - return false; - }, - }); - expect(view.kind).toBe('updating'); - if (view.kind === 'updating') { - expect(range.period(view.displayedInput)).toEqual({ - ...displayed, - kind: 'calendar', - timeZone: 'UTC', - }); - expect(view.message).toContain(range.describeInput(requested)); - } - expect( - resolveDataView({ - requestedInput: requested, - current: { input: requested, data: { count: 0 } }, - pending: false, - sameInput(left, right) { - return left.start === right.start && left.end === right.end; - }, - describe: range.describeInput, - isEmpty(data) { - return data.count === 0; - }, - }) - ).toEqual({ kind: 'empty', input: requested }); -}); - -test('comparison uses the preceding equal-length range and respects source coverage', () => { - expect(previousDateRange({ start: '2026-03-01', end: '2026-03-03' })).toEqual( - { - start: '2026-02-26', - end: '2026-02-28', - } - ); - const contract = defineDateRangeContract({ - minDate: '2026-02-27', - maxDate: '2026-03-31', - maxRangeDays: 31, - timeZone: 'UTC', - }); - expect( - contract.comparison({ start: '2026-03-04', end: '2026-03-06' }) - ).toEqual({ - start: '2026-03-01', - end: '2026-03-03', - }); - expect( - contract.comparison({ start: '2026-03-01', end: '2026-03-03' }) - ).toBeNull(); - expect( - contract.describeInput({ start: '2026-03-04', end: '2026-03-06' }) - ).toBe('Mar 4–6, 2026 UTC'); -}); - -test('app appearance accepts supported settings and falls back for invalid configuration', () => { - expect( - parseAppearance({ density: 'compact', cornerRadius: 'small' }) - ).toMatchObject({ - density: 'compact', - cornerRadius: 'small', - }); - expect(parseAppearance({ cornerRadius: 'roundish' })).toMatchObject({ - cornerRadius: 'medium', - }); -}); diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts deleted file mode 100644 index bf4c741c..00000000 --- a/tests/boundaries.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect, test } from 'bun:test'; -import { readFile } from 'node:fs/promises'; -import { resolve, sep } from 'node:path'; - -const source = resolve(import.meta.dir, '../src'); -const layers = { - core: new Set(['core']), - client: new Set(['core', 'client']), - server: new Set(['core', 'server']), - embed: new Set(['core', 'client', 'embed']), - worker: new Set(['worker']), -} as const; - -test('non-React layers import only their allowed source layers', async () => { - for (const [layer, allowed] of Object.entries(layers)) { - const root = resolve(source, layer); - for await (const name of new Bun.Glob('**/*.{ts,tsx}').scan(root)) { - const path = resolve(root, name); - const contents = await readFile(path, 'utf8'); - const imports = contents.matchAll( - /\b(?:from|import)\s*(?:\(\s*)?["']([^"']+)["']/g - ); - for (const [, specifier] of imports) { - if (!specifier) continue; - if (!specifier.startsWith('@/')) { - expect( - specifier, - `${name} must not import React or its UI dependencies` - ).not.toMatch( - /^(?:react(?:-dom|-aria-components)?|@tanstack\/react-query|@floating-ui\/react|lucide-react)\b/ - ); - continue; - } - const target = resolve(source, '..', specifier.slice(2)); - expect( - target.startsWith(`${source}${sep}`), - `${name} imports outside src` - ).toBe(true); - const targetLayer = target.slice(source.length + 1).split(sep)[0]; - expect( - allowed.has(targetLayer), - `${name} imports ${specifier} from ${targetLayer}` - ).toBe(true); - } - expect(contents, `${name} must not import CSS`).not.toMatch( - /\bimport\s*["'][^"']+\.css["']/ - ); - } - } -}); - -test('React implementation modules do not import the public entry', async () => { - const root = resolve(source, 'react'); - const entries = new Set([ - resolve(root, 'index.ts'), - resolve(root, 'embed/index.ts'), - ]); - for await (const name of new Bun.Glob('**/*.{ts,tsx}').scan(root)) { - const path = resolve(root, name); - if (entries.has(path)) continue; - const contents = await readFile(path, 'utf8'); - const imports = contents.matchAll( - /\b(?:from|import)\s*(?:\(\s*)?["']([^"']+)["']/g - ); - for (const [, specifier] of imports) { - if (!specifier?.startsWith('@/')) continue; - expect( - entries.has(`${resolve(source, '..', specifier.slice(2))}.ts`), - `${name} imports a public React entry` - ).toBe(false); - } - } -}); diff --git a/tests/bridge-protocol.test.ts b/tests/bridge-protocol.test.ts deleted file mode 100644 index 75d214c9..00000000 --- a/tests/bridge-protocol.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { expect, test } from 'bun:test'; -import fixtures from '@/tests/fixtures/bridge-v1.json'; -import { - BRIDGE, - bridgeProtocol, - parseBridgeMessage, - type BridgeMessage, - type BridgeEventName, - type BridgeHandlers, - type BridgeRole, -} from '@/src/core/bridge'; -import { createBridgeEndpoint } from '@/src/core/bridge-endpoint'; - -function harness( - role: BridgeRole = 'host', - opaque = true, - diagnostic?: ( - direction: 'send' | 'receive', - type: BridgeMessage['type'] - ) => void -) { - const source = {} as Window; - const received: unknown[] = []; - const sent: unknown[] = []; - const invalid: string[] = []; - const context: { documentId?: string; sessionId?: string; token?: string } = { - documentId: 'document', - sessionId: 'session', - token: 'token', - }; - const handlers = Object.fromEntries( - Object.entries(bridgeProtocol.events) - .filter(([, event]) => event.from !== role) - .map(([name]) => [name, (message: unknown) => received.push(message)]) - ) as unknown as BridgeHandlers; - const endpoint = createBridgeEndpoint({ - role, - handlers, - origin: 'https://peer.example', - source: () => source, - opaque, - context: () => context, - post: message => sent.push(message), - invalidRequest: id => invalid.push(id), - diagnostic, - }); - function receive( - fields: Record, - origin = 'https://peer.example', - peer = source - ) { - endpoint.receive({ - data: { - channel: BRIDGE, - version: 1, - documentId: 'document', - sessionId: 'session', - token: 'token', - ...fields, - }, - origin, - source: peer, - } as MessageEvent); - } - return { - endpoint, - receive, - received, - sent, - invalid, - context, - source, - receiveWire(message: unknown) { - endpoint.receive({ - data: structuredClone(message), - origin: 'https://peer.example', - source, - } as MessageEvent); - }, - }; -} - -test('version 1 fixtures lock every event wire shape for producers and consumers', () => { - expect(fixtures.map(fixture => fixture.name).sort()).toEqual( - Object.keys(bridgeProtocol.events).sort() - ); - for (const fixture of fixtures) { - const from = fixture.from as BridgeRole; - const producer = harness(from); - if (fixture.name === 'connect' || fixture.name === 'ready') - producer.context.sessionId = undefined; - const { - channel: _channel, - version: _version, - type: _type, - documentId: _documentId, - sessionId: _sessionId, - token: _token, - ...payload - } = fixture.message; - producer.endpoint.send(fixture.name as BridgeEventName, payload as never); - expect(structuredClone(producer.sent[0])).toEqual(fixture.message); - const consumer = harness(from === 'host' ? 'app' : 'host'); - consumer.receiveWire(fixture.message); - expect(structuredClone(consumer.received[0])).toEqual(fixture.message); - } -}); - -test('parsers reject malformed event fields and project future optional fields', () => { - for (const fixture of fixtures) { - expect(parseBridgeMessage({ ...fixture.message, future: true })).toEqual( - parseBridgeMessage(fixture.message) - ); - expect( - parseBridgeMessage({ ...fixture.message, version: 2 }) - ).toBeUndefined(); - } - const envelope = { - channel: BRIDGE, - version: 1, - documentId: 'document', - sessionId: 'session', - }; - for (const fields of [ - { type: 'bridge:request', id: 'request', route: '' }, - { type: 'bridge:request', id: 'request', route: 'x'.repeat(257) }, - { type: 'bridge:cancel', id: 3 }, - { type: 'bridge:cancel', id: 'bad id' }, - { type: 'bridge:result', id: 'request' }, - { type: 'bridge:error', id: 'request', code: 3, message: 'bad' }, - { - type: 'bridge:error', - id: 'request', - code: 'bad', - message: 'bad', - requestId: 3, - }, - { type: 'script:load', javascript: 3 }, - { type: 'future:event' }, - ]) - expect(parseBridgeMessage({ ...envelope, ...fields })).toBeUndefined(); - expect( - parseBridgeMessage({ - ...envelope, - type: 'bridge:result', - id: 'request', - response: undefined, - }) - ).toBeDefined(); -}); - -test('endpoints reject unknown, wrong-direction and stale events before dispatch', () => { - const host = harness(); - const message = { - type: 'bridge:request', - id: 'request', - route: 'test:ping', - payload: null, - }; - host.receive({ ...message, type: 'future:event' }); - host.receive({ type: 'bridge:result', id: 'request', response: 3 }); - host.receive({ ...message, sessionId: 'stale' }); - host.receive({ ...message, documentId: 'stale' }); - host.receive({ ...message, token: 'stale' }); - host.receive(message, 'https://evil.example'); - host.receive(message, 'https://peer.example', {} as Window); - expect(host.received).toHaveLength(0); - host.receive({ ...message, future: true }); - expect(host.received).toHaveLength(1); - expect(host.received[0]).not.toHaveProperty('future'); -}); - -test('only authenticated current-session malformed requests expose a correlated error', () => { - const host = harness(); - const malformed = { type: 'bridge:request', id: 'request', route: 3 }; - host.receive({ ...malformed, sessionId: 'stale' }); - host.receive({ ...malformed, token: 'stale' }); - host.receive({ ...malformed, documentId: 'stale' }); - host.receive(malformed, 'https://evil.example'); - host.receive({ ...malformed, id: 'invalid id' }); - expect(host.invalid).toHaveLength(0); - host.receive(malformed); - expect(host.invalid).toEqual(['request']); - expect(host.received).toHaveLength(0); -}); - -test('handshake events work before a session and ordinary events wait for initialization', () => { - const app = harness('app'); - app.context.sessionId = undefined; - app.context.token = undefined; - app.receive({ - type: 'bridge:connect', - documentId: 'host', - token: 'new-token', - }); - expect(app.received).toHaveLength(1); - app.receive({ type: 'bridge:initialize', token: 'new-token', state: null }); - expect(app.received).toHaveLength(1); - app.context.token = 'new-token'; - app.receive({ type: 'bridge:initialize', token: 'new-token', state: null }); - expect(app.received).toHaveLength(2); - app.receive({ type: 'state:update', token: 'new-token', state: null }); - expect(app.received).toHaveLength(2); - expect(() => app.endpoint.send('runtimeReady', {})).toThrow(); - app.context.sessionId = 'session'; - app.receive({ type: 'state:update', token: 'new-token', state: null }); - expect(app.received).toHaveLength(3); - app.endpoint.dispose(); - app.receive({ type: 'state:update', token: 'new-token', state: null }); - expect(app.received).toHaveLength(3); - expect(() => app.endpoint.send('runtimeReady', {})).toThrow(); -}); - -test('diagnostic disposal stops posting while subsequent sends remain strict', () => { - const host = harness('host', true, direction => { - if (direction === 'send') host.endpoint.dispose(); - }); - expect(() => - host.endpoint.send('result', { id: 'request', response: undefined }) - ).not.toThrow(); - expect(host.sent).toHaveLength(0); - expect(() => - host.endpoint.send('result', { id: 'request', response: 3 }) - ).toThrow('closed'); -}); - -test('diagnostic disposal stops receive dispatch', () => { - const host = harness('host', true, direction => { - if (direction === 'receive') host.endpoint.dispose(); - }); - host.receive({ - type: 'bridge:request', - id: 'request', - route: 'test:ping', - payload: undefined, - }); - expect(host.received).toHaveLength(0); -}); - -test('structured clone preserves undefined request payloads and result fields', () => { - for (const opaque of [false, true]) { - const app = harness('app', opaque); - const host = harness('host', opaque); - app.endpoint.send('request', { - id: 'request', - route: 'test:ping', - payload: undefined, - }); - const request = structuredClone(app.sent[0]) as Record; - expect(Object.hasOwn(request, 'payload')).toBe(true); - expect(request.payload).toBeUndefined(); - expect(Object.hasOwn(request, 'token')).toBe(opaque); - host.receiveWire(request); - expect(structuredClone(host.received[0])).toEqual(request); - host.endpoint.send('result', { id: 'request', response: undefined }); - const result = structuredClone(host.sent[0]) as Record; - expect(Object.hasOwn(result, 'response')).toBe(true); - expect(result.response).toBeUndefined(); - app.receiveWire(result); - expect(structuredClone(app.received[0])).toEqual(result); - delete result.response; - app.receiveWire(result); - expect(app.received).toHaveLength(1); - } -}); - -test('structured clone preserves absent optional envelope fields before the handshake', () => { - const app = harness('app', false); - app.context.sessionId = undefined; - app.endpoint.send('ready', {}); - const ready = structuredClone(app.sent[0]) as Record; - expect(Object.hasOwn(ready, 'sessionId')).toBe(false); - expect(Object.hasOwn(ready, 'token')).toBe(false); - const host = harness('host', false); - host.receiveWire(ready); - expect(structuredClone(host.received[0])).toEqual(ready); -}); diff --git a/tests/bridge-protocol.types.ts b/tests/bridge-protocol.types.ts deleted file mode 100644 index 74b67ab6..00000000 --- a/tests/bridge-protocol.types.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createBridgeEndpoint } from '@/src/core/bridge-endpoint'; -import type { BridgeHandlers } from '@/src/core/bridge'; - -function verifyProtocol() { - const app = createBridgeEndpoint({ - role: 'app', - origin: 'https://host.example', - source: () => null, - opaque: false, - context: () => ({ documentId: 'doc', sessionId: 'session' }), - post() {}, - handlers: { - connect() {}, - initialize(message) { - const session: string = message.sessionId; - void session; - }, - scriptLoad(message) { - const javascript: string = message.javascript; - void javascript; - }, - stateUpdate() {}, - result(message) { - const response: unknown = message.response; - void response; - }, - error(message) { - const id: string = message.id; - void id; - // @ts-expect-error Error messages do not contain results. - void message.response; - }, - // @ts-expect-error Apps cannot handle their own outgoing events. - request() {}, - }, - }); - app.send('request', { id: 'id', route: 'test:ping', payload: undefined }); - app.send('ready', {}); - // @ts-expect-error Handler registration is complete at construction. - app.handle({}); - // @ts-expect-error Event names come from the catalog. - app.send('requset', { id: 'id', route: 'test:ping', payload: undefined }); - // @ts-expect-error Request IDs are mandatory. - app.send('request', { route: 'test:ping', payload: undefined }); - // @ts-expect-error Payload is part of the request contract even when undefined. - app.send('request', { id: 'id', route: 'test:ping' }); - // @ts-expect-error Apps cannot send host events. - app.send('result', { id: 'id', response: 3 }); - app.send('request', { - id: 'id', - route: 'test:ping', - payload: undefined, - // @ts-expect-error Envelope fields belong to the endpoint. - sessionId: 'override', - }); - // @ts-expect-error Empty events cannot carry envelope overrides. - app.send('ready', { documentId: 'override' }); - const override = { documentId: 'override' }; - // @ts-expect-error Variables cannot override envelopes either. - app.send('ready', override); -} - -function verifyHandlers(handlers: BridgeHandlers<'host'>) { - const endpoint = createBridgeEndpoint({ - role: 'host', - handlers, - origin: 'https://app.example', - source: () => null, - opaque: false, - context: () => ({ documentId: 'doc', sessionId: 'session' }), - post() {}, - }); - endpoint.send('error', { - id: 'request', - code: 'forbidden', - message: 'Denied', - }); - // @ts-expect-error Hosts cannot send requests. - endpoint.send('request', { id: 'id', route: 'test:ping', payload: null }); -} - -void verifyProtocol; -void verifyHandlers; - -function verifyConstruction() { - const options = { - role: 'app' as const, - origin: 'https://host.example', - source: () => null, - opaque: false, - context: () => ({ documentId: 'doc' }), - post() {}, - }; - // @ts-expect-error An endpoint cannot be constructed without handlers. - createBridgeEndpoint(options); - createBridgeEndpoint({ - ...options, - // @ts-expect-error Every incoming event needs a handler at construction. - handlers: { connect() {} }, - }); -} -void verifyConstruction; diff --git a/tests/bridge-react.test.tsx b/tests/bridge-react.test.tsx deleted file mode 100644 index 8ea51553..00000000 --- a/tests/bridge-react.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { DataAppBridge } from '@altertable/data-app/react/embed'; - -async function onMessage() { - return null; -} - -test('source bridge leaves presentation and visibility to the consuming shell', () => { - const markup = renderToStaticMarkup( - - ); - expect(markup).toBe( - '' - ); - const hidden = renderToStaticMarkup( - - ); - expect(hidden).toContain('hidden=""'); -}); - -test('connection bridge leaves iframe rendering to the host', () => { - expect( - renderToStaticMarkup( - - ) - ).toBe(''); -}); diff --git a/tests/bridge-source.test.ts b/tests/bridge-source.test.ts deleted file mode 100644 index 811b0300..00000000 --- a/tests/bridge-source.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { expect, test } from 'bun:test'; -import { attachDataAppBridge } from '@altertable/data-app/embed'; - -function iframe() { - const host = Object.assign(new EventTarget(), { - location: { - href: 'https://host.example/report', - origin: 'https://host.example', - search: '', - hash: '', - }, - }) as unknown as Window; - const attributes = new Map(); - const frame = Object.assign(new EventTarget(), { - ownerDocument: { defaultView: host }, - contentWindow: { postMessage() {} }, - setAttribute(name: string, value: string) { - attributes.set(name, value); - }, - src: '', - referrerPolicy: '', - }) as unknown as HTMLIFrameElement; - - return { frame, attributes }; -} - -test('source bridge rejects non-HTTP sources and URL apps sharing the host origin', () => { - const { frame } = iframe(); - for (const url of [ - 'javascript:alert(1)', - 'file:///app.html', - 'https://host.example/app', - ]) { - expect(() => - attachDataAppBridge({ - iframe: frame, - source: { type: 'url', url }, - async onMessage() { - return null; - }, - }) - ).toThrow(); - } -}); - -test('source bridge reports startup failure and cleanup cancels pending startup timers', async () => { - const { frame, attributes } = iframe(); - const statuses: string[] = []; - const host = attachDataAppBridge({ - iframe: frame, - source: { - type: 'bundle', - bootstrapUrl: '/runtime', - javascript: '', - }, - async onMessage() { - return null; - }, - onStatusChange(value) { - return statuses.push(value); - }, - startupTimeoutMs: 10, - }); - expect(attributes.get('sandbox')).toBe('allow-scripts'); - expect(frame.referrerPolicy).toBe('no-referrer'); - await Bun.sleep(30); - expect(statuses).toEqual(['connecting', 'failed']); - host.dispose(); - const next = attachDataAppBridge({ - iframe: frame, - source: { type: 'url', url: 'https://app.example/report' }, - async onMessage() { - return null; - }, - onStatusChange(value) { - return statuses.push(value); - }, - startupTimeoutMs: 10, - }); - expect(new URL(frame.src).searchParams.get('__altertable_parent')).toBe( - 'https://host.example' - ); - next.dispose(); - await Bun.sleep(30); - expect(statuses).toEqual(['connecting', 'failed', 'connecting']); -}); diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts deleted file mode 100644 index 95aac0b6..00000000 --- a/tests/bridge.test.ts +++ /dev/null @@ -1,870 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - parseCount, - createMessageRouter, - dataAppRoutes, - defineMessageRoute, -} from '@altertable/data-app/contract'; -import { - createDataAppNavigation, - createIframeTransport, -} from '@altertable/data-app/client'; - -import { attachDataAppBridge } from '@altertable/data-app/embed'; -import { - BRIDGE, - MAX_PENDING, - type BridgeMessage, - type TransportResponse, -} from '@/src/core/bridge'; - -function harness() { - const events = new EventTarget(); - const parent = { - postMessage(message: BridgeMessage, origin: string) { - sent.push({ message, origin }); - }, - }; - const sent: { message: BridgeMessage; origin: string }[] = []; - const frame = Object.assign(events, { parent }) as unknown as Window; - const bridge = createIframeTransport({ - parentOrigin: 'http://localhost:1', - window: frame, - timeoutMs: 50, - }); - const documentId = sent[0]!.message.documentId; - - function receive( - message: Partial, - origin = 'http://localhost:1', - source = parent - ) { - events.dispatchEvent( - Object.assign(new Event('message'), { - origin, - source: source as unknown as Window, - data: { - channel: BRIDGE, - version: 1, - documentId, - sessionId: 'session', - ...message, - }, - }) - ); - } - - return { bridge, receive, sent, parent, frame }; -} - -test('iframe waits for a trusted handshake, correlates concurrent responses and preserves envelopes', async () => { - const { bridge, receive, sent } = harness(); - try { - const first = bridge.transport('first', { n: 1 }); - const second = bridge.transport('second', { n: 2 }); - receive({ type: 'bridge:initialize' }, 'http://evil.example'); - receive({ type: 'bridge:initialize' }, 'http://localhost:1', { - postMessage() {}, - }); - expect( - sent.filter(({ message }) => message.type === 'bridge:request') - ).toHaveLength(0); - receive({ type: 'bridge:initialize' }); - const queries = sent - .map(entry => entry.message) - .filter(message => message.type === 'bridge:request'); - expect(queries).toHaveLength(2); - expect(sent.every(({ origin }) => origin === 'http://localhost:1')).toBe( - true - ); - const response = { - status: 200, - body: { - data: 2, - requestId: 'server-request', - queriedAt: '2026-09-30T00:00:00Z', - queryIds: ['q2'], - }, - }; - receive({ type: 'bridge:result', id: queries[1]!.id, response }); - expect(await second).toEqual(response); - receive({ - type: 'bridge:result', - id: queries[0]!.id, - sessionId: 'stale', - response, - }); - receive({ - type: 'bridge:error', - id: queries[0]!.id, - code: 'forbidden', - message: 'Denied', - }); - expect(await first.catch(error => error)).toMatchObject({ - code: 'forbidden', - message: 'Denied', - }); - } finally { - bridge.dispose(); - } -}); - -test('iframe aborts before and after dispatch and times out missing hosts', async () => { - const { bridge, receive, sent } = harness(); - try { - const controller = new AbortController(); - const first = bridge.transport('first', {}, controller.signal); - controller.abort(); - expect(await first.catch(error => error)).toMatchObject({ - name: 'AbortError', - }); - receive({ type: 'bridge:initialize' }); - expect(sent.some(({ message }) => message.type === 'bridge:request')).toBe( - false - ); - const active = new AbortController(); - const second = bridge.transport('second', {}, active.signal); - active.abort(); - expect(await second.catch(error => error)).toMatchObject({ - name: 'AbortError', - }); - expect(sent.at(-1)!.message.type).toBe('bridge:cancel'); - } finally { - bridge.dispose(); - } - const missing = harness(); - try { - expect( - await missing.bridge.transport('connection', {}).catch(error => error) - ).toMatchObject({ - code: 'bridge_unavailable', - }); - } finally { - missing.bridge.dispose(); - } -}); - -test('iframe supports 50 simultaneous operation requests', async () => { - const { bridge, receive, sent } = harness(); - try { - receive({ type: 'bridge:initialize' }); - const calls = Array.from({ length: 50 }, () => - bridge.transport('connection', {}) - ); - const requests = sent.filter( - ({ message }) => message.type === 'bridge:request' - ); - expect(requests).toHaveLength(50); - for (const { message } of requests) { - if (message.type === 'bridge:request') - receive({ - type: 'bridge:result', - id: message.id, - response: { - status: 200, - body: { - data: true, - requestId: 'parallel', - queriedAt: 'now', - queryIds: [], - }, - }, - }); - } - expect(await Promise.all(calls)).toHaveLength(50); - } finally { - bridge.dispose(); - } -}); - -test('iframe bounds pending work and rejects old work on session replacement', async () => { - const { bridge, receive } = harness(); - try { - receive({ type: 'bridge:initialize' }); - const calls = Array.from({ length: MAX_PENDING }, () => - bridge.transport('connection', {}).catch(error => error.code) - ); - expect( - await bridge.transport('overflow', {}).catch(error => error) - ).toMatchObject({ - code: 'bridge_busy', - }); - receive({ type: 'bridge:initialize', sessionId: 'replacement' }); - expect(await Promise.all(calls)).toEqual( - Array(MAX_PENDING).fill('bridge_reset') - ); - } finally { - bridge.dispose(); - } -}); - -test('bridge rejects foreign sources, invalid input, duplicate IDs and stale sessions; reload cancels handler work', async () => { - const events = new EventTarget(); - const sent: BridgeMessage[] = []; - const target = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - }; - const calls: { - operation: string; - input: unknown; - signal: AbortSignal; - resolve: (value: TransportResponse) => void; - reject: (error: unknown) => void; - }[] = []; - const host = Object.assign(events, { - location: { search: '', hash: '' }, - }) as unknown as Window; - const connection = attachDataAppBridge({ - iframe: Object.assign(new EventTarget(), { - contentWindow: target, - }) as unknown as HTMLIFrameElement, - connection: { type: 'origin', origin: 'http://localhost:2' }, - window: host, - onMessage: createMessageRouter( - { - ...dataAppRoutes, - 'test:ping': defineMessageRoute({ - input(value: unknown): undefined { - if (value !== undefined) - throw new Error('Unexpected ping payload.'); - - return undefined; - }, - output: parseCount, - }), - }, - { - 'data:query'({ operation, input }, { signal }) { - return new Promise((resolve, reject) => - calls.push({ operation, input, signal, resolve, reject }) - ); - }, - 'navigation:update'() { - return null; - }, - 'test:ping'() { - return 7; - }, - } - ).dispatch, - }); - let sessionId: string | undefined; - - function receive( - message: Partial, - origin = 'http://localhost:2', - source = target - ) { - events.dispatchEvent( - Object.assign(new Event('message'), { - origin, - source: source as unknown as Window, - data: { - channel: BRIDGE, - version: 1, - documentId: 'document', - sessionId, - ...message, - }, - }) - ); - } - - try { - receive({ type: 'bridge:ready' }, 'http://evil.example'); - receive({ type: 'bridge:ready' }, 'http://localhost:2', { - postMessage() {}, - }); - expect(sent).toHaveLength(1); - expect(sent[0]!.type).toBe('bridge:connect'); - host.dispatchEvent(new Event('popstate')); - expect(sent).toHaveLength(1); - receive({ type: 'bridge:ready' }); - sessionId = sent.at(-1)!.sessionId; - receive({ type: 'bridge:request', id: 'malformed', route: '' }); - expect(sent.at(-1)).toMatchObject({ - type: 'bridge:error', - id: 'malformed', - code: 'invalid_message', - }); - receive({ - type: 'bridge:request', - id: 'ping', - route: 'test:ping', - payload: undefined, - }); - await Bun.sleep(0); - expect(sent.at(-1)).toMatchObject({ - type: 'bridge:result', - id: 'ping', - response: 7, - }); - receive({ - type: 'bridge:request', - id: 'invalid', - route: 'data:query', - payload: { operation: 'https://evil.example', input: {} }, - }); - receive({ - type: 'bridge:request', - id: 'big', - route: 'data:query', - payload: { operation: 'connection', input: 'x'.repeat(16_384) }, - }); - receive({ - type: 'bridge:request', - id: 'stale', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - sessionId: 'old', - }); - expect(calls).toHaveLength(0); - receive({ - type: 'bridge:request', - id: 'call', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - }); - receive({ - type: 'bridge:request', - id: 'call', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - }); - expect(calls).toHaveLength(1); - expect(calls[0]!.operation).toBe('connection'); - expect(calls[0]!.input).toEqual({}); - const response = { - status: 403, - body: { error: { code: 'forbidden', message: 'Denied' } }, - }; - calls[0]!.resolve(response); - await Bun.sleep(0); - expect(sent.at(-1)).toMatchObject({ - type: 'bridge:error', - id: 'call', - code: 'forbidden', - message: 'Denied', - }); - receive({ - type: 'bridge:request', - id: 'failed', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - }); - calls[1]!.reject(new Error('Private handler details')); - await Bun.sleep(0); - expect(sent.at(-1)).toMatchObject({ - type: 'bridge:error', - id: 'failed', - code: 'request_failed', - message: 'The message request failed. Retry the request.', - }); - receive({ - type: 'bridge:request', - id: 'invalid-response', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - }); - calls[2]!.resolve({ status: 0, body: {} }); - await Bun.sleep(0); - expect(sent.at(-1)).toMatchObject({ - type: 'bridge:error', - id: 'invalid-response', - code: 'invalid_response', - }); - receive({ - type: 'bridge:request', - id: 'reload', - route: 'data:query', - payload: { operation: 'connection', input: {} }, - }); - receive({ type: 'bridge:ready', documentId: 'next-document' }); - expect(calls[3]!.signal.aborted).toBe(true); - calls[3]!.resolve({ status: 200, body: { data: 'late' } }); - await Bun.sleep(0); - expect( - sent.some( - message => message.type === 'bridge:result' && message.id === 'reload' - ) - ).toBe(false); - } finally { - connection.dispose(); - } -}); - -test('opaque bridge requires source, null origin and token; reload aborts old work and rotates its token', async () => { - const events = new EventTarget(); - const sent: BridgeMessage[] = []; - const target = { - postMessage(message: BridgeMessage, origin: string) { - expect(origin).toBe('*'); - sent.push(message); - }, - }; - const iframe = Object.assign(new EventTarget(), { - contentWindow: target, - }) as unknown as HTMLIFrameElement; - const host = Object.assign(events, { - location: { search: '?period=last-30', hash: '#totals' }, - }) as unknown as Window; - let signal: AbortSignal | undefined; - let finish: ((result: unknown) => void) | undefined; - const statuses: string[] = []; - const connection = attachDataAppBridge({ - iframe, - connection: { type: 'opaque', token: 'initial-token' }, - window: host, - onStatusChange(status) { - return statuses.push(status); - }, - onMessage(_, context) { - signal = context.signal; - - return new Promise(resolve => { - finish = resolve; - }); - }, - }); - - function receive( - message: Partial, - origin = 'null', - source: unknown = target - ) { - return events.dispatchEvent( - Object.assign(new Event('message'), { - origin, - source, - data: { - channel: BRIDGE, - version: 1, - documentId: 'document', - token: 'initial-token', - ...message, - }, - }) - ); - } - - try { - receive({ type: 'bridge:ready', token: 'wrong' }); - receive({ type: 'bridge:ready' }, 'http://localhost:2'); - receive({ type: 'bridge:ready' }, 'null', {}); - expect(sent).toHaveLength(1); - receive({ type: 'bridge:ready' }); - const sessionId = sent.at(-1)!.sessionId; - receive({ - type: 'bridge:request', - sessionId, - id: 'pending', - route: 'test:echo', - payload: {}, - }); - expect(signal?.aborted).toBe(false); - iframe.dispatchEvent(new Event('load')); - expect(signal?.aborted).toBe(true); - const token = sent.at(-1)!.token; - expect(token).not.toBe('initial-token'); - receive({ type: 'bridge:ready' }); - expect(sent.at(-1)!.type).toBe('bridge:connect'); - receive({ type: 'bridge:ready', token }); - expect(sent.at(-1)!.type).toBe('bridge:initialize'); - const initialized = sent.at(-1)!; - if (initialized.type !== 'bridge:initialize') - throw new Error('Expected initialize.'); - expect(initialized.state).toEqual({ - search: '?period=last-30', - hash: '#totals', - logging: false, - }); - finish?.('old result'); - await Promise.resolve(); - expect(sent.some(message => message.type === 'bridge:result')).toBe(false); - expect(statuses).toContain('connected'); - } finally { - connection.dispose(); - } -}); - -test('bundle transport keeps host location in memory and rejects stale session tokens', async () => { - const events = new EventTarget(); - const sent: BridgeMessage[] = []; - const parent = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - }; - const frame = Object.assign(events, { - parent, - document: { title: 'Bundle' }, - }) as unknown as Window; - let executions = 0; - const bridge = createIframeTransport({ - parentOrigin: 'http://localhost:1', - window: frame, - mode: 'bundle', - loadScript() { - executions++; - }, - timeoutMs: 100, - }); - - function receive(message: Partial) { - return events.dispatchEvent( - Object.assign(new Event('message'), { - origin: 'http://localhost:1', - source: parent, - data: { - channel: BRIDGE, - version: 1, - documentId: sent.at(-1)?.documentId ?? 'host', - sessionId: 'session', - token: 'token', - ...message, - }, - }) - ); - } - - try { - expect(sent).toHaveLength(0); - receive({ type: 'bridge:connect', documentId: 'host' }); - const documentId = sent.at(-1)!.documentId; - receive({ - type: 'bridge:initialize', - documentId, - state: { search: '?period=last-30', hash: '#totals' }, - }); - const navigation = createDataAppNavigation({ bridge, window: frame }); - expect(navigation.snapshot()).toEqual({ - search: '?period=last-30', - hash: '#totals', - }); - receive({ - type: 'script:load', - documentId, - token: 'stale', - javascript: 'code', - }); - expect(executions).toBe(0); - receive({ type: 'script:load', documentId, javascript: 'code' }); - expect(executions).toBe(1); - navigation.update({ search: '?period=last-7', hash: '#daily' }, 'push'); - const request = sent.at(-1)!; - if (request.type !== 'bridge:request') throw new Error('Expected request.'); - expect(request.route).toBe('navigation:update'); - expect(request.payload).toEqual({ - search: '?period=last-7', - hash: '#daily', - mode: 'push', - title: 'Bundle', - }); - receive({ - type: 'bridge:result', - documentId, - id: request.id, - response: null, - }); - receive({ - type: 'state:update', - documentId, - state: { search: '?period=last-30', hash: '#totals' }, - }); - expect(navigation.snapshot().search).toBe('?period=last-30'); - } finally { - bridge.dispose(); - } -}); - -test('browser-owned operations send SQL and bounded limits over the authenticated bridge', async () => { - const { createDataClient } = await import('@altertable/data-app/client'); - const { defineOperation } = await import('@altertable/data-app/contract'); - const { bridge, receive, sent } = harness(); - try { - const count = defineOperation({ - input: parseCount, - output: parseCount, - checks: [3], - queryNames: { count: 'count' }, - policy: { maxQueryRows: 1, maxDurationMs: 1000, exposeSql: true }, - async run({ query }, input) { - const result = await query('count', `SELECT ${input}`, { limit: 10 }); - return result.rows[0]![0]; - }, - }); - const client = createDataClient({ - operations: { count }, - lakehouse: bridge.lakehouse, - }); - const pending = client.query('count', 3); - receive({ type: 'bridge:initialize' }); - await Promise.resolve(); - const message = sent.find( - entry => entry.message.type === 'bridge:request' - )!.message; - if (message.type !== 'bridge:request') throw new Error('Expected request.'); - expect(message.route).toBe('data:sql'); - expect(message.payload).toEqual({ statement: 'SELECT 3', limit: 1 }); - receive({ - type: 'bridge:result', - id: message.id, - response: { columns: [{ name: 'n' }], rows: [[3]], queryId: 'q1' }, - }); - expect(await pending).toMatchObject({ - data: 3, - input: 3, - queryIds: ['q1'], - queries: [{ name: 'count', statement: 'SELECT 3', queryId: 'q1' }], - }); - const failed = client.query('count', 3); - await Promise.resolve(); - const failedMessage = sent - .map(entry => entry.message) - .filter(message => message.type === 'bridge:request') - .at(-1)!; - receive({ - type: 'bridge:error', - id: failedMessage.id, - code: 'source_rate_limited', - message: 'The lakehouse is busy.', - requestId: 'host-request', - }); - expect(await failed.catch(error => error)).toMatchObject({ - code: 'source_rate_limited', - requestId: 'host-request', - }); - const controller = new AbortController(); - const cancelled = client.query('count', 3, { signal: controller.signal }); - await Promise.resolve(); - const request = sent - .map(entry => entry.message) - .filter(message => message.type === 'bridge:request') - .at(-1)!; - controller.abort(); - expect(await cancelled.catch(error => error)).toMatchObject({ - name: 'AbortError', - }); - expect(sent.at(-1)!.message).toMatchObject({ - type: 'bridge:cancel', - id: request.id, - }); - } finally { - bridge.dispose(); - } -}); - -test('presentation context follows only authenticated current-session state', () => { - const { bridge, receive, parent } = harness(); - const dark = { - search: '?period=last-30', - hash: '#totals', - presentation: { surface: 'embedded', theme: 'dark' }, - }; - const light = { - ...dark, - presentation: { surface: 'embedded', theme: 'light' }, - }; - const states: unknown[] = []; - const unsubscribe = bridge.subscribe(state => states.push(state)); - try { - receive({ type: 'state:update', state: light }); - expect(bridge.snapshot()).toBeUndefined(); - receive({ type: 'bridge:initialize', state: dark }); - expect(bridge.snapshot()).toEqual(dark); - receive( - { type: 'state:update', state: light }, - 'http://evil.example', - parent - ); - receive({ type: 'state:update', state: light }, 'http://localhost:1', { - postMessage() {}, - }); - receive({ type: 'state:update', state: light, sessionId: 'stale' }); - receive({ type: 'state:update', state: light, documentId: 'stale' }); - expect(states).toEqual([dark]); - receive({ type: 'state:update', state: light }); - expect(states).toEqual([dark, light]); - expect(bridge.snapshot()).toEqual(light); - } finally { - unsubscribe(); - bridge.dispose(); - } -}); - -test('bundle requests wait for a token and session, and runtime callbacks tolerate disposal', async () => { - const events = new EventTarget(); - const sent: BridgeMessage[] = []; - const parent = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - }; - const frame = Object.assign(events, { parent }) as unknown as Window; - const bridge = createIframeTransport({ - parentOrigin: 'https://host.example', - window: frame, - mode: 'bundle', - }); - function receive(message: Record) { - events.dispatchEvent( - Object.assign(new Event('message'), { - origin: 'https://host.example', - source: parent, - data: { - channel: BRIDGE, - version: 1, - token: 'token', - sessionId: 'session', - ...message, - }, - }) - ); - } - try { - bridge.ready(); - bridge.fail(); - const pending = bridge.request({ route: 'test:ping', payload: undefined }); - expect(sent).toHaveLength(0); - receive({ type: 'bridge:connect', documentId: 'host' }); - const documentId = sent[0]!.documentId; - expect(sent[0]!.type).toBe('bridge:ready'); - expect(sent.some(message => message.type === 'bridge:request')).toBe(false); - receive({ type: 'bridge:initialize', documentId }); - const request = sent.find(message => message.type === 'bridge:request')!; - receive({ - type: 'bridge:result', - documentId, - id: request.id, - response: undefined, - }); - expect(await pending).toBeUndefined(); - bridge.dispose(); - const count = sent.length; - expect(() => bridge.ready()).not.toThrow(); - expect(() => bridge.fail()).not.toThrow(); - expect(sent).toHaveLength(count); - } finally { - bridge.dispose(); - } -}); - -/** Invoke message listeners directly so synchronous callback failures stay observable. */ -function callbackWindow(origin: string, source: Window) { - const listeners = new Map void>(); - const window = { - location: { search: '', hash: '' }, - parent: source, - addEventListener(type: string, listener: (event: MessageEvent) => void) { - listeners.set(type, listener); - }, - removeEventListener(type: string) { - listeners.delete(type); - }, - } as unknown as Window; - return { - window, - receive: (message: Record) => { - listeners.get('message')?.({ - origin, - source, - data: { channel: BRIDGE, version: 1, ...message }, - } as MessageEvent); - }, - }; -} - -test('host stops initialization when the connected callback disposes the bridge', () => { - const sent: BridgeMessage[] = []; - const target = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - } as unknown as Window; - const { window, receive } = callbackWindow('https://app.example', target); - const bridge = attachDataAppBridge({ - iframe: Object.assign(new EventTarget(), { - contentWindow: target, - }) as unknown as HTMLIFrameElement, - connection: { type: 'origin', origin: 'https://app.example' }, - window, - javascript: 'app()', - onMessage: async () => null, - onStatusChange(status) { - if (status === 'connected') bridge.dispose(); - }, - }); - try { - expect(() => - receive({ type: 'bridge:ready', documentId: 'document' }) - ).not.toThrow(); - expect(sent.map(message => message.type)).toEqual(['bridge:connect']); - } finally { - bridge.dispose(); - } -}); - -test('iframe stops initialization when a state subscriber disposes the transport', () => { - const sent: BridgeMessage[] = []; - const parent = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - } as unknown as Window; - const { window, receive } = callbackWindow('https://host.example', parent); - const bridge = createIframeTransport({ - parentOrigin: 'https://host.example', - window, - }); - const documentId = sent[0]!.documentId; - bridge.subscribe(() => bridge.dispose()); - try { - expect(() => - receive({ - type: 'bridge:initialize', - documentId, - sessionId: 'session', - state: null, - }) - ).not.toThrow(); - expect(sent.map(message => message.type)).toEqual([ - 'bridge:ready', - 'bridge:disconnect', - ]); - } finally { - bridge.dispose(); - } -}); - -test('host stops the handshake when a send diagnostic disposes the bridge', () => { - const sent: BridgeMessage[] = []; - const target = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - } as unknown as Window; - const { window, receive } = callbackWindow('https://app.example', target); - const bridge = attachDataAppBridge({ - iframe: Object.assign(new EventTarget(), { - contentWindow: target, - }) as unknown as HTMLIFrameElement, - connection: { type: 'origin', origin: 'https://app.example' }, - window, - javascript: 'app()', - onMessage: async () => null, - onDiagnostic({ direction, type }) { - if (direction === 'send' && type === 'bridge:initialize') - bridge.dispose(); - }, - }); - try { - expect(() => - receive({ type: 'bridge:ready', documentId: 'document' }) - ).not.toThrow(); - expect(sent.map(message => message.type)).toEqual(['bridge:connect']); - } finally { - bridge.dispose(); - } -}); diff --git a/tests/chart-data.test.tsx b/tests/chart-data.test.tsx deleted file mode 100644 index 1359ac8b..00000000 --- a/tests/chart-data.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { - BarChart, - LineChart, - AreaChart, - PieChart, - ScatterChart, -} from '@altertable/data-app/react/ui'; - -test('direct chart composition validates dynamic items consistently', () => { - const rows = [ - { id: 'a', label: 'A', value: 1 }, - { id: 'a', label: 'B', value: 2 }, - ]; - for (const Chart of [BarChart, LineChart, AreaChart, PieChart]) { - expect(() => - renderToStaticMarkup( - - ) - ).toThrow('duplicate "a"'); - } - expect(() => - renderToStaticMarkup( - ({ ...row, x: row.value, y: 0 }))} - xLabel="X" - yLabel="Y" - xUnit="" - yUnit="" - ariaLabel="Observations" - /> - ) - ).toThrow('duplicate "a"'); -}); - -test('chart components reject invalid values while allowing signed trends', () => { - for (const Chart of [BarChart, LineChart, AreaChart, PieChart]) { - for (const item of [ - { id: ' ', label: 'Blank', value: 1 }, - { id: 'a', label: 'A', value: NaN }, - { id: 'a', label: 'A', value: Infinity }, - ]) { - expect(() => - renderToStaticMarkup( - - ) - ).toThrow(); - } - } - const items = [{ id: 'a', label: 'Loss', value: -1 }]; - for (const Chart of [BarChart, PieChart]) - expect(() => - renderToStaticMarkup( - - ) - ).toThrow('nonnegative'); - for (const Chart of [LineChart, AreaChart]) - expect( - renderToStaticMarkup( - - ) - ).toContain('Loss'); - expect(() => - renderToStaticMarkup( - - ) - ).toThrow('finite'); -}); diff --git a/tests/client.test.ts b/tests/client.test.ts deleted file mode 100644 index 558e3414..00000000 --- a/tests/client.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - createDataClient, - createIframeTransport, - installDataAppTransport, - getDataAppTransport, -} from '@altertable/data-app/client'; -import { BRIDGE, type BridgeMessage } from '@/src/core/bridge'; -import type { connectionCheck } from '@altertable/data-app/contract'; - -test('data client discovers the iframe while explicit transport and HTTP options retain priority', async () => { - const global = globalThis as unknown as { window?: Window }; - const previousWindow = global.window; - const previousFetch = globalThis.fetch; - const frame = Object.assign(new EventTarget(), { - parent: { postMessage() {} }, - location: { href: 'https://app.example/' }, - }) as unknown as Window; - const bridge = createIframeTransport({ - parentOrigin: 'https://host.example', - window: frame, - }); - const body = { - data: true, - requestId: 'request', - queriedAt: 'now', - queryIds: [], - }; - let iframeCalls = 0; - - async function requestFromIframe() { - iframeCalls++; - - return { status: 200, body }; - } - - bridge.transport = requestFromIframe; - const urls: string[] = []; - - async function request(input: string | URL | Request): Promise { - urls.push( - typeof input === 'string' - ? input - : input instanceof URL - ? input.href - : input.url - ); - - return Response.json(body); - } - - global.window = frame; - globalThis.fetch = request as typeof fetch; - const uninstall = installDataAppTransport(bridge, frame); - type Operations = { connection: ReturnType }; - try { - await createDataClient().query('connection', {}); - expect(iframeCalls).toBe(1); - await createDataClient({ endpoint: '' }).query( - 'connection', - {} - ); - await createDataClient({ - fetch: request as typeof fetch, - }).query('connection', {}); - expect(urls).toEqual(['/connection', '/api/data/connection']); - expect(iframeCalls).toBe(1); - const response = await createDataClient({ - endpoint: '/ignored', - async transport() { - return { - status: 200, - body: { ...body, requestId: 'custom' }, - }; - }, - }).query('connection', {}); - expect(response.requestId).toBe('custom'); - expect(urls).toHaveLength(2); - } finally { - uninstall(); - globalThis.fetch = previousFetch; - if (previousWindow) global.window = previousWindow; - else delete global.window; - } -}); - -function previewFrame(verify: () => Promise, topLevel = false) { - const origin = 'https://host.example'; - const frame = Object.assign(new EventTarget(), { - location: { - href: `https://app.example/?__altertable_parent=${encodeURIComponent(origin)}`, - }, - document: { title: 'Preview fixture' }, - fetch() { - return verify(); - }, - }) as unknown as Window; - let handshakes = 0; - const parent = { - postMessage(message: BridgeMessage) { - function receive(data: Partial) { - frame.dispatchEvent( - Object.assign(new Event('message'), { - source: parent, - origin, - data: { - channel: BRIDGE, - version: 1, - documentId: message.documentId, - sessionId: 'preview', - ...data, - }, - }) - ); - } - if (message.type === 'bridge:ready') { - handshakes++; - queueMicrotask(() => receive({ type: 'bridge:initialize' })); - } - if (message.type === 'bridge:request') - queueMicrotask(() => - receive({ - type: 'bridge:result', - id: message.id, - response: { - status: 200, - body: { - data: true, - requestId: 'preview', - queriedAt: 'now', - queryIds: [], - }, - }, - }) - ); - }, - }; - Object.assign(frame, { parent: topLevel ? frame : parent }); - - return { - frame, - handshakes(this: void) { - return handshakes; - }, - }; -} - -async function inPreview(frame: Window, run: () => Promise) { - const globals = globalThis as unknown as { window?: Window }; - const previous = globals.window; - globals.window = frame; - try { - await run(); - } finally { - getDataAppTransport(frame)?.dispose(); - if (previous) globals.window = previous; - else delete globals.window; - } -} - -type PreviewOperations = { connection: ReturnType }; - -test('an explicit retry re-verifies a recovered local preview', async () => { - let fetches = 0; - const { frame, handshakes } = previewFrame(async () => { - if (++fetches === 1) throw new Error('Temporary outage'); - - return Response.json({ origin: 'https://host.example' }); - }); - await inPreview(frame, async () => { - const client = createDataClient(); - expect( - await client.query('connection', {}).catch(error => error) - ).toMatchObject({ - code: 'bridge_unavailable', - }); - expect((await client.query('connection', {})).data).toBe(true); - expect( - (await createDataClient().query('connection', {})).data - ).toBe(true); - expect(fetches).toBe(2); - expect(handshakes()).toBe(1); - }); -}); - -test('concurrent preview callers share verification and cancellation affects only its caller', async () => { - let fetches = 0; - const config = Promise.withResolvers(); - const { frame, handshakes } = previewFrame(() => { - fetches++; - return config.promise; - }); - await inPreview(frame, async () => { - const client = createDataClient(); - const controller = new AbortController(); - const first = client.query('connection', {}, { signal: controller.signal }); - const second = createDataClient().query( - 'connection', - {} - ); - controller.abort(); - expect(await first.catch(error => error)).toMatchObject({ - name: 'AbortError', - }); - expect(fetches).toBe(1); - config.resolve(Response.json({ origin: 'https://host.example' })); - expect((await second).data).toBe(true); - expect(handshakes()).toBe(1); - }); -}); - -test('preview retries continue rejecting mismatched origins and top-level windows', async () => { - for (const topLevel of [false, true]) { - let fetches = 0; - const { frame, handshakes } = previewFrame(async () => { - fetches++; - - return Response.json({ - origin: topLevel ? 'https://host.example' : 'https://evil.example', - }); - }, topLevel); - await inPreview(frame, async () => { - const client = createDataClient(); - for (let attempt = 0; attempt < 2; attempt++) { - expect( - await client.query('connection', {}).catch(error => error) - ).toMatchObject({ - code: 'bridge_unavailable', - }); - } - expect(fetches).toBe(2); - expect(handshakes()).toBe(0); - }); - } -}); - -test('pending verification cannot overwrite a transport installed by another owner', async () => { - const config = Promise.withResolvers(); - const { frame, handshakes } = previewFrame(() => config.promise); - await inPreview(frame, async () => { - const client = createDataClient(); - const pending = client.query('connection', {}); - const installed = createIframeTransport({ - parentOrigin: 'https://host.example', - window: frame, - }); - const uninstall = installDataAppTransport(installed, frame); - try { - config.resolve(Response.json({ origin: 'https://host.example' })); - expect((await pending).data).toBe(true); - expect(getDataAppTransport(frame)).toBe(installed); - expect(handshakes()).toBe(1); - } finally { - uninstall(); - } - }); -}); - -test('HTTP and iframe delivery reject the same malformed success envelopes', async () => { - const { defineDataQueryRoute } = - await import('@altertable/data-app/contract'); - const valid = { - data: true, - requestId: 'request', - queriedAt: 'now', - queryIds: [], - }; - const bodies = [ - {}, - { requestId: 'request', queriedAt: 'now', queryIds: [] }, - { ...valid, requestId: undefined }, - { ...valid, queriedAt: undefined }, - { ...valid, queryIds: [1] }, - { ...valid, queries: [{ name: 'query', statement: 1 }] }, - [], - ]; - for (const body of bodies) { - const client = createDataClient({ - fetch: Object.assign(async () => Response.json(body), { - preconnect() {}, - }), - }); - expect( - await client.query('connection', {}).catch(error => error) - ).toMatchObject({ code: 'invalid_response' }); - expect(() => - defineDataQueryRoute().output( - { status: 200, body }, - { operation: 'connection', input: {} } - ) - ).toThrow(); - } -}); diff --git a/tests/csv-export.test.ts b/tests/csv-export.test.ts deleted file mode 100644 index f1801f43..00000000 --- a/tests/csv-export.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { expect, test } from 'bun:test'; -import { unzipSync, strFromU8 } from 'fflate'; -import { - createCsvDownload, - formatCsv, - type CsvExport, -} from '@/src/react/ui/csv-export'; - -test('CSV preserves column order, Unicode, raw numbers, zero, booleans and null cells', () => { - expect( - formatCsv({ - columns: ['Name', 'Value', 'Enabled', 'Missing'], - rows: [ - ['München', 1234.5, true, null], - ['Zero', 0, false, undefined], - ], - }) - ).toBe( - 'Name,Value,Enabled,Missing\r\nMünchen,1234.5,true,\r\nZero,0,false,\r\n' - ); -}); - -test('CSV quotes commas, quotes, and both newline characters', () => { - expect( - formatCsv({ - columns: ['Name, label', 'Text'], - rows: [ - ['He said "hello"', 'First\nsecond'], - ['München', 'First\rsecond'], - ], - }) - ).toBe( - '"Name, label",Text\r\n"He said ""hello""","First\nsecond"\r\nMünchen,"First\rsecond"\r\n' - ); -}); - -test('CSV protects spreadsheet formulas in string cells while keeping numeric negatives', () => { - expect( - formatCsv({ - columns: ['Value'], - rows: [ - ['=1+1'], - [' +SUM(A1)'], - ['-10'], - ['@SUM(A1)'], - ['\tformula'], - [-10], - ], - }) - ).toBe( - "Value\r\n'=1+1\r\n' +SUM(A1)\r\n'-10\r\n'@SUM(A1)\r\n'\tformula\r\n-10\r\n" - ); -}); - -test('CSV supports header-only files and rejects inconsistent table shapes', () => { - expect(formatCsv({ columns: ['Name'], rows: [] })).toBe('Name\r\n'); - expect(() => formatCsv({ columns: [], rows: [] })).toThrow(); - expect(() => formatCsv({ columns: ['Name'], rows: [['A', 1]] })).toThrow(); -}); - -test('multiple datasets download as a ZIP with distinct complete CSV files', async () => { - const tables: CsvExport['tables'] = [ - { name: 'Groups', columns: ['Name', 'Count'], rows: [['München', 0]] }, - { name: 'Summary', columns: ['Total'], rows: [[0]] }, - ]; - const file = createCsvDownload({ filename: 'report', tables }); - expect(file.filename).toBe('report.zip'); - expect(file.route).toBe('export:zip'); - const files = unzipSync(new Uint8Array(await file.blob.arrayBuffer())); - expect(Object.keys(files)).toEqual(['Groups.csv', 'Summary.csv']); - expect(Array.from(files['Groups.csv']!.slice(0, 3))).toEqual([239, 187, 191]); - expect(strFromU8(files['Groups.csv']!)).toBe('Name,Count\r\nMünchen,0\r\n'); - expect(strFromU8(files['Summary.csv']!)).toBe('Total\r\n0\r\n'); - const selected = createCsvDownload({ filename: 'report', tables }, tables[1]); - expect(selected.filename).toBe('Summary.csv'); - expect(selected.route).toBe('export:csv'); - expect(await selected.blob.text()).toBe('\uFEFFTotal\r\n0\r\n'.slice(1)); -}); -test('export rejects empty collections, duplicate names and archive paths', () => { - const table = { name: 'Groups', columns: ['Name'], rows: [] }; - // @ts-expect-error Exports require at least one dataset; reject invalid JavaScript callers too. - expect(() => createCsvDownload({ filename: 'report', tables: [] })).toThrow(); - expect(() => - createCsvDownload({ filename: 'report', tables: [table, table] }) - ).toThrow(); - expect(() => - createCsvDownload({ - filename: 'report', - tables: [{ ...table, name: '../groups' }], - }) - ).toThrow(); -}); - -test('ZIP compresses repetitive CSV data and preserves its exact bytes', async () => { - const table = { - name: 'Activity', - columns: ['Group', 'Count'], - rows: Array.from({ length: 1000 }, () => ['Alpha', 3]), - }; - const file = createCsvDownload({ - filename: 'report', - tables: [table, { ...table, name: 'Comparison' }], - }); - const original = new TextEncoder().encode('\uFEFF' + formatCsv(table)); - const archive = new Uint8Array(await file.blob.arrayBuffer()); - expect(archive.byteLength).toBeLessThan(original.byteLength); - const files = unzipSync(archive); - expect(files['Activity.csv']).toEqual(original); - expect(files['Comparison.csv']).toEqual(original); -}); diff --git a/tests/data-identifiers.test.tsx b/tests/data-identifiers.test.tsx deleted file mode 100644 index 1ce0929a..00000000 --- a/tests/data-identifiers.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { defineDataIdentifiers } from '@altertable/data-app/react'; - -const identifiers = defineDataIdentifiers({ - tables: { - events: { - catalog: 'product_analytics', - schema: 'analytics', - name: 'events', - }, - }, - columns: { - identityUuid: { table: 'events', name: 'identity_uuid' }, - }, -}); - -test('data identifiers render exact source names from compact IDs', () => { - const { DataIdentifier } = identifiers; - const html = renderToStaticMarkup( -

- uses{' '} - . -

- ); - expect(html.replace(/<[^>]*>/g, '')).toContain( - 'product_analytics.analytics.events uses identity_uuid.' - ); - expect(html).toContain('data-kind="table"'); - expect(html).toContain('data-kind="column"'); - expect(html).toContain( - 'title="product_analytics.analytics.events.identity_uuid"' - ); - expect(identifiers.definitions['columns.events.identityUuid']).toEqual({ - kind: 'column', - tableId: 'tables.events', - table: { - catalog: 'product_analytics', - schema: 'analytics', - name: 'events', - }, - name: 'identity_uuid', - }); -}); - -test('aliases cannot obscure the compact ID structure', () => { - expect(() => - defineDataIdentifiers({ - tables: { - 'events.archive': { catalog: 'a', schema: 'b', name: 'events' }, - }, - columns: {}, - }) - ).toThrow('Invalid data identifier alias events.archive.'); -}); - -test('unknown references fail rather than rendering misleading text', () => { - const { DataIdentifier } = identifiers; - expect(() => - renderToStaticMarkup( - - ) - ).toThrow('Unknown data identifier tables.missing.'); -}); - -const unknownIdentifier = ( - // @ts-expect-error Unknown reference IDs must fail app typechecking. - -); -void unknownIdentifier; - -function invalidIdentifierDefinition() { - const unknownTable = defineDataIdentifiers({ - tables: { events: { catalog: 'a', schema: 'b', name: 'c' } }, - // @ts-expect-error A column must name a registered table. - columns: { id: { table: 'missing', name: 'id' } }, - }); - - return unknownTable; -} - -void invalidIdentifierDefinition; diff --git a/tests/derived-data.test.tsx b/tests/derived-data.test.tsx deleted file mode 100644 index 0f2cae35..00000000 --- a/tests/derived-data.test.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import { ownedSource } from '@/src/react/source-owner'; -import { sectionContent } from '@/src/react/content'; -import { createDataHooks } from '@/src/react/hooks'; -import { DataSection } from '@/src/react/ui/DataSection'; -import { - MetricWidget, - TableWidget, - VisualizationWidget, - DataValue, - TextWidget, -} from '@/src/react/widgets'; -import { - exportDatasets, - displayedScope, - datasetTable, - datasetCsv, - registeredEvidence, -} from '@/src/react/bindings'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { dateRangeVariable } from '@altertable/data-app/react'; -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup as renderMarkup } from 'react-dom/server'; -import type { ReactNode } from 'react'; -import { DataAppProvider } from '@/src/react/mount'; -import { createDataClient } from '@/src/client/data-client'; -import { - defineDateRangeContract, - type DataOperation, - type DateRangeRequest, -} from '@altertable/data-app/contract'; -import { createDataContext } from '@altertable/data-app/react'; -import { displayedSnapshot } from '@/src/core/data-view'; -import { formatCsv, createCsvDownload } from '@/src/react/ui/csv-export'; - -function renderToStaticMarkup(content: ReactNode) { - return renderMarkup({content}); -} - -const context = createDataContext({ rows: 'rows' })({ - description: 'Rows', - glossary: { - count: { - term: 'Count', - definition: 'Measured count', - queryNames: ['rows'], - }, - }, -}); -const metric = { - id: 'count', - glossaryId: 'count', - format: { kind: 'count' }, -} as const; -type Row = { id: string; count: number | null; available: boolean }; -const hooks = createDataHooks<{ - rows: DataOperation<{ scope: string }, readonly Row[]>; -}>(createDataClient()); -const view = hooks.defineDataView({ - dataContext: context, - operation: 'rows', - input: () => ({ scope: 'current' }), - describeInput: input => input.scope, - isEmpty: rows => rows.length === 0, - emptyFallback: { title: 'No rows' }, -}); - -function dataset( - select: (rows: readonly Row[]) => readonly Row[] = rows => rows -) { - return view.dataset({ - select, - name: 'Counts', - rowKey: row => row.id, - evidence: { - id: 'dataset', - queryNames: [context.queryNames.rows], - }, - columns: { - id: { label: 'ID', value: row => row.id }, - count: { value: row => row.count, format: { kind: 'count' } }, - available: { - value: row => row.available, - format: row => (row.available ? 'Yes' : 'No'), - }, - }, - }); -} -const snapshot = ownedSource(view, { - state: 'ready' as const, - input: { scope: 'displayed' }, - data: [ - { id: 'A', count: 1234, available: true }, - { id: 'B', count: 0, available: false }, - { id: 'C', count: null, available: false }, - ], -}); - -test('datasets derive loading tables, formatted cells, and raw CSV from one declaration', () => { - let selections = 0; - const counts = dataset(rows => { - selections++; - return rows; - }); - const loading = renderToStaticMarkup( - - ); - expect(selections).toBe(0); - expect(loading).toContain('Count'); - expect(loading).toContain('altertable-skeleton'); - const ready = renderToStaticMarkup( - - ); - expect(ready).toContain('1,234'); - expect(ready).toContain('—'); - expect(datasetTable(counts, snapshot).columns[1].type).toBe('number'); - expect(datasetTable(counts, snapshot).columns[2].type).toBeUndefined(); - expect(ready).toContain('Yes'); - expect(ready).toContain('No'); - const csv = datasetCsv(counts, snapshot); - expect(csv.rows).toEqual([ - ['A', 1234, true], - ['B', 0, false], - ['C', null, false], - ]); - expect(formatCsv(csv)).toBe( - 'ID,Count,Available\r\nA,1234,true\r\nB,0,false\r\nC,,false\r\n' - ); - expect(counts.read(snapshot).value).toBe(snapshot.data); - const empty = renderToStaticMarkup( - - ); - expect(empty).toContain('No results'); -}); - -test('dataset scope and export callbacks retain displayed input through refresh and failure', () => { - const counts = dataset(); - const other = view.dataset({ - name: 'Names', - select: rows => rows, - rowKey: row => row.id, - evidence: { - id: 'dataset', - queryNames: [context.queryNames.rows], - }, - columns: { id: { label: 'ID', value: row => row.id } }, - }); - for (const kind of ['updating', 'stale-error'] as const) { - const displayed = ownedSource( - view, - displayedSnapshot({ - kind, - data: snapshot.data, - displayedInput: { scope: 'Prior / scope' }, - requestedInput: { scope: 'New scope' }, - message: 'Prior', - error: new Error('Failed'), - })! - ); - expect(view.scope(displayed)).toBe('Prior / scope'); - expect( - exportDatasets([counts], displayed, view.scope(displayed), 'Rows') - .filename - ).toBe('counts-prior-scope'); - expect( - exportDatasets([counts], displayed, view.scope(displayed), 'Rows') - .tables[0].rows - ).toEqual(datasetCsv(counts, snapshot).rows); - const all = exportDatasets( - [counts, other], - displayed, - view.scope(displayed), - 'Rows' - ); - expect(all.tables.map(table => table.name)).toEqual(['Counts', 'Names']); - expect(createCsvDownload(all).filename).toBe('rows-prior-scope.zip'); - } - const content = view.content(state => ( -

{state.scope.loading ? 'Loading scope' : state.scope.value}

- )); - expect( - renderToStaticMarkup(sectionContent(content).loadingFallback) - ).toContain('Loading scope'); - expect( - renderToStaticMarkup( - sectionContent(content).children(snapshot.data, snapshot.input) - ) - ).toContain('displayed'); -}); - -test('bound metrics derive loading, zero, evidence, and period comparisons for widgets and stories', () => { - let selections = 0; - const count = view.metric(metric, rows => { - selections++; - return { current: rows.reduce((sum, row) => sum + (row.count ?? 0), 0) }; - }); - renderToStaticMarkup( - - ); - expect(selections).toBe(0); - expect(count.definition).toEqual(context.metric(metric)); - expect(count.read(snapshot).value.current).toBe(1234); - expect( - count.read( - ownedSource(view, { - ...snapshot, - data: [{ id: 'zero', count: 0, available: true }], - }) - ).value.current - ).toBe(0); - const calendar = defineDateRangeContract({ - maxRangeDays: 30, - timeZone: 'UTC', - }); - const timeHooks = createDataHooks<{ - timed: DataOperation; - }>(createDataClient()); - const timed = timeHooks.defineTimeView({ - dataContext: context, - operation: 'timed', - time: { - contract: calendar, - defaultValue: { kind: 'preset', id: 'last-7' }, - }, - isEmpty: () => false, - emptyFallback: { title: 'No data' }, - }); - const comparison = timed.metric(metric, current => ({ - current, - previous: 0, - })); - const period = calendar.request( - { start: '2026-03-01', end: '2026-03-03' }, - true - ); - expect( - comparison.read( - ownedSource(timed, { state: 'ready' as const, data: 3, input: period }) - ).value.period - ).toEqual(period); - expect( - comparison.read( - ownedSource(timed, { state: 'ready' as const, data: 3, input: period }) - ).value.previous - ).toBe(0); - expect(() => - view.metric(metric, () => ({ current: 1, previous: 0 })).read(snapshot) - ).toThrow('date binding'); -}); - -test('keyed datasets retain explicit accessors and identity for projected rows', () => { - const counts = view.dataset({ - name: 'Counts', - select: rows => rows, - rowKey: row => row.id, - evidence: { - id: 'counts', - queryNames: [context.queryNames.rows], - }, - columns: { - id: { value: row => row.id }, - count: { value: row => row.count, format: { kind: 'count' } }, - doubled: { - value: row => (row.count === null ? null : row.count * 2), - format: { kind: 'count' }, - }, - available: { label: ' ', value: row => row.available }, - }, - }); - expect(counts.read(snapshot).value).toBe(snapshot.data); - expect(datasetTable(counts, snapshot).rowKey(snapshot.data[0]!)).toBe('A'); - expect( - datasetTable(counts, snapshot).columns.map(column => column.id) - ).toEqual(['id', 'count', 'doubled', 'available']); - expect(datasetCsv(counts, snapshot).columns).toEqual([ - 'Id', - 'Count', - 'Doubled', - 'Available', - ]); - expect(datasetCsv(counts, snapshot).rows[0]).toEqual(['A', 1234, 2468, true]); - const projection = view.dataset({ - name: 'Projected', - select: rows => rows.map(row => ({ key: row.id, sampleCount: row.count })), - rowKey: row => row.key, - evidence: { id: 'dataset', queryNames: [context.queryNames.rows] }, - columns: { - key: { value: row => row.key }, - sampleCount: { value: row => row.sampleCount }, - }, - }); - expect(datasetCsv(projection, snapshot).columns).toEqual([ - 'Key', - 'Sample count', - ]); - expect(datasetCsv(projection, snapshot).rows[0]).toEqual(['A', 1234]); - const composite = view.dataset({ - name: 'Composite', - select: rows => rows, - rowKey: row => `${row.id}:${row.available}`, - evidence: { id: 'dataset', queryNames: [context.queryNames.rows] }, - columns: { id: { value: row => row.id } }, - }); - expect(datasetTable(composite, snapshot).rowKey(snapshot.data[0]!)).toBe( - 'A:true' - ); -}); - -test('hook scope uses the automatic date description for a manually composed view', () => { - const frame = { location: new URL('https://app.example.com') }; - Object.assign(frame, { top: frame }); - const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: frame, - }); - const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: Infinity } }, - }); - const timeHooks = createDataHooks<{ - timed: DataOperation; - }>(createDataClient()); - const calendar = defineDateRangeContract({ - maxRangeDays: 30, - timeZone: 'UTC', - }); - const period = dateRangeVariable({ - key: 'period', - contract: calendar, - defaultValue: { kind: 'dates', start: '2026-03-01', end: '2026-03-03' }, - }); - const dateView = timeHooks.defineDataView({ - dataContext: context, - operation: 'timed', - variables: { period }, - input: values => values.period, - date: { variable: 'period', input: input => input }, - isEmpty: value => value === 0, - emptyFallback: { title: 'No data' }, - }); - const dateContent = dateView.content(result => ( -

{result.scope.loading ? 'Loading scope' : result.scope.value}

- )); - function Scope() { - return ; - } - function render() { - return renderToStaticMarkup( - - - - ); - } - try { - expect(render()).toContain('Loading scope'); - const query = queryClient.getQueryCache().getAll()[0]!; - const input = period.input(period.defaultValue); - queryClient.setQueryData(query.queryKey, { data: 3, input, queries: [] }); - expect(render()).toContain(period.describeInput(input)); - expect(render()).not.toContain('this view'); - queryClient.setQueryData(query.queryKey, { data: 0, input, queries: [] }); - expect(render()).toContain('No data'); - expect( - displayedScope({ kind: 'empty', input }, period.describeInput) - ).toEqual({ loading: false, value: period.describeInput(input) }); - } finally { - queryClient.clear(); - if (previousWindow) - Object.defineProperty(globalThis, 'window', previousWindow); - else Reflect.deleteProperty(globalThis, 'window'); - } -}); - -test('datasets validate empty or blank column declarations at the boundary', () => { - const definition = { - name: 'Rows', - select: (rows: readonly Row[]) => rows, - rowKey: (row: Row) => row.id, - evidence: { - id: 'rows', - queryNames: [context.queryNames.rows] as const, - }, - }; - expect(() => view.dataset({ ...definition, columns: {} })).toThrow( - 'column IDs' - ); - expect(() => - view.dataset({ ...definition, columns: { ' ': { value: row => row.id } } }) - ).toThrow('column IDs'); -}); - -test('dataset visualizations derive displayed rows, evidence, and empty copy without loading selectors', () => { - let selections = 0; - const counts = dataset(rows => { - selections++; - return rows; - }); - function visual(source: { loading: true } | typeof snapshot) { - return renderToStaticMarkup( - - {rows => ( -

- {rows[0]?.id}: {rows[0]?.count ?? 'missing'} -

- )} -
- ); - } - expect(visual({ loading: true })).toContain('altertable-content-skeleton'); - expect(selections).toBe(0); - expect(visual(snapshot)).toContain('A: 1234'); - expect(visual(snapshot)).toContain('Explore Counts'); - expect(visual({ ...snapshot, data: [] })).toContain('No results'); - const updating = displayedSnapshot({ - kind: 'updating', - data: snapshot.data, - requestedInput: { scope: 'pending' }, - displayedInput: snapshot.input, - message: 'Updating', - })!; - const rows = counts.read(ownedSource(view, updating)); - expect(rows.value).toBe(snapshot.data); -}); - -test('narrative bindings preserve loading, formatting, measured zero, and displayed evidence', () => { - let selections = 0; - const total = view.metric(metric, rows => { - selections++; - return { current: rows.reduce((sum, row) => sum + (row.count ?? 0), 0) }; - }); - const loading = renderToStaticMarkup( - <> - - - - ); - expect(selections).toBe(0); - expect(loading).toContain('altertable-skeleton'); - expect(loading).toContain('Count'); - const ready = renderToStaticMarkup( - <> - - - - ); - expect(ready).toContain('1,234'); - expect(ready).toContain('Explore Count'); - expect( - renderToStaticMarkup( - - ) - ).toContain('>0'); - const counts = dataset(); - expect( - renderToStaticMarkup( - - {rows => (rows.length ? 'Rows' : 'No recorded rows')} - - ) - ).toContain('No recorded rows'); - expect( - renderToStaticMarkup( - - {rows => rows[0]?.id} - - ) - ).toContain('>A'); - expect( - renderToStaticMarkup( - - ) - ).toContain('displayed scope'); -}); - -test('views register metric labels and evidence against their own context before reading data', () => { - const count = view.metric(metric, rows => ({ current: rows.length })); - expect(count.definition.label).toBe('Count'); - expect(count.definition.evidence.glossaryIds).toEqual(['count']); - expect(count.definition.evidence.queryNames).toEqual(['rows']); - expect(() => - view.metric({ ...metric, glossaryId: 'missing' as 'count' }, () => { - throw new Error('Selector must not run'); - }) - ).toThrow('Unknown glossary entry'); -}); - -test('dataset evidence is registered by its view before data selection', () => { - let selections = 0; - const definition = { - name: 'Counts', - select: (rows: readonly Row[]) => { - selections++; - return rows; - }, - rowKey: (row: Row) => row.id, - columns: { id: { value: (row: Row) => row.id } }, - }; - const counts = view.dataset({ - ...definition, - evidence: { id: 'counts', glossaryIds: ['count'] }, - }); - expect(counts.evidence.glossaryIds).toEqual(['count']); - expect(selections).toBe(0); - expect(() => - view.dataset({ - ...definition, - evidence: { id: 'bad', queryNames: ['missing' as 'rows'] }, - }) - ).toThrow('Unknown query name'); - expect(selections).toBe(0); -}); - -test('same-shaped foreign sources and story bindings fail before selectors or exports', () => { - const foreign = hooks.defineDataView({ - dataContext: context, - operation: 'rows', - input: () => ({ scope: 'foreign' }), - describeInput: input => input.scope, - isEmpty: () => false, - emptyFallback: { title: 'Empty' }, - }); - let selections = 0; - const counts = dataset(rows => { - selections++; - return rows; - }); - const total = view.metric(metric, () => { - selections++; - return { current: 1 }; - }); - const other = ownedSource(foreign, snapshot); - expect(() => counts.read(other)).toThrow('own view'); - expect(() => total.read(other)).toThrow('own view'); - expect(() => - counts.read(ownedSource(foreign, { loading: true as const })) - ).toThrow('own view'); - expect(() => counts.read({ ...snapshot })).toThrow('own view'); - expect(() => datasetCsv(counts, other)).toThrow('own view'); - expect(selections).toBe(0); - expect(registeredEvidence(counts, view)).toBe(counts.evidence); - expect(registeredEvidence(total, view)).toBe(total.definition.evidence); - expect(() => registeredEvidence(counts, foreign)).toThrow('story view'); - expect(() => registeredEvidence(counts.evidence as never, view)).toThrow( - 'registered metric or dataset' - ); -}); diff --git a/tests/dimension.test.ts b/tests/dimension.test.ts deleted file mode 100644 index 78d7fd84..00000000 --- a/tests/dimension.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - dimensionFilter, - dimensionPredicate, - parseDimensionSelection, - parseFacetOptions, - type DimensionSelection, -} from '@altertable/data-app/contract'; -import { resolveViewInput } from '@/src/react/view'; - -const interfaceFilter = dimensionFilter({ - key: 'interface', - label: 'Interface', - valueType: 'string', - selection: 'multiple', - options: [ - { value: 'HTTP', label: 'HTTP' }, - { value: 'null', label: 'Literal null' }, - ], - allowMissing: true, -}); - -test('dimension URLs distinguish All, literal null, and missing', () => { - const all = { kind: 'all' } as const; - const selected = { - kind: 'include' as const, - members: [ - { kind: 'value' as const, value: 'null' }, - { kind: 'missing' as const }, - ], - }; - expect(interfaceFilter.write(all).interface).toBeNull(); - const params = new URLSearchParams(); - params.set('interface', interfaceFilter.write(selected).interface!); - expect(interfaceFilter.read(params)).toEqual(selected); - expect(interfaceFilter.read(new URLSearchParams())).toEqual(all); - expect(dimensionPredicate('variant', selected, ['variant'])).toBe( - "(variant IN ('null') OR variant IS NULL)" - ); - expect(dimensionPredicate('variant', all, ['variant'])).toBe(''); -}); - -test('filter input preserves selected members and rejects forged values', () => { - const selected = { - kind: 'include' as const, - members: [{ kind: 'value' as const, value: 'HTTP' }], - }; - const definition = { - input(values: { interface: DimensionSelection }) { - return { interface: values.interface }; - }, - variables: { interface: interfaceFilter }, - }; - expect(resolveViewInput(definition, { interface: selected })).toEqual({ - interface: selected, - }); - expect(() => - resolveViewInput( - { - ...definition, - input() { - return { interface: { kind: 'all' as const } }; - }, - }, - { interface: selected } - ) - ).toThrow('preserve'); - expect(() => - parseDimensionSelection({ kind: 'include', members: [] }, interfaceFilter) - ).toThrow(); - expect(() => - parseDimensionSelection( - { kind: 'include', members: [{ kind: 'value', value: 'unlisted' }] }, - interfaceFilter - ) - ).toThrow(); - expect(interfaceFilter.read(new URLSearchParams('interface=bogus'))).toEqual({ - kind: 'all', - }); - expect(() => - dimensionPredicate('variant; DROP TABLE users', selected, [ - 'variant', - ] as string[]) - ).toThrow(); -}); - -test('SQL literals escape quotes even for a validated dimension value', () => { - expect( - dimensionPredicate( - 'variant', - { - kind: 'include', - members: [{ kind: 'value', value: "O'Brien" }], - }, - ['variant'] - ) - ).toBe("(variant IN ('O''Brien'))"); -}); - -test('facets accept bounded typed options and keep selected values valid when absent', () => { - const facet = dimensionFilter({ - key: 'category', - label: 'Category', - valueType: 'string', - selection: 'multiple', - facet: { - operation: 'category-facet', - input(values) { - return { period: values.period }; - }, - }, - }); - expect( - parseFacetOptions([{ value: 'A', label: 'Alpha', count: 0 }], facet) - ).toEqual([{ value: 'A', label: 'Alpha', count: 0 }]); - expect( - parseDimensionSelection( - { kind: 'include', members: [{ kind: 'value', value: 'A' }] }, - facet - ) - ).toEqual({ kind: 'include', members: [{ kind: 'value', value: 'A' }] }); - expect(() => - parseFacetOptions( - [ - { value: 'A', label: 'Alpha' }, - { value: 'A', label: 'Duplicate' }, - ], - facet - ) - ).toThrow('Duplicate'); - expect(() => - parseFacetOptions([{ value: 'A', label: 'Alpha', count: -1 }], facet) - ).toThrow('Invalid'); - expect(() => - parseDimensionSelection( - { kind: 'include', members: [{ kind: 'value', value: 'x'.repeat(101) }] }, - facet - ) - ).toThrow(); -}); - -test('dimension resets to All and enforces selection limits', () => { - const filter = dimensionFilter({ - key: 'region', - label: 'Region', - valueType: 'string', - selection: 'multiple', - options: [{ value: 'eu', label: 'Europe' }], - maxSelected: 1, - }); - expect(filter.read(new URLSearchParams())).toEqual({ kind: 'all' }); - expect( - filter.valid({ kind: 'include', members: [{ kind: 'missing' }] }) - ).toBe(false); - expect( - filter.valid({ kind: 'include', members: [{ kind: 'value', value: 'eu' }] }) - ).toBe(true); -}); diff --git a/tests/fixtures/bridge-v1.json b/tests/fixtures/bridge-v1.json deleted file mode 100644 index 1f18ec0d..00000000 --- a/tests/fixtures/bridge-v1.json +++ /dev/null @@ -1,178 +0,0 @@ -[ - { - "name": "connect", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:connect", - "documentId": "host", - "token": "token" - } - }, - { - "name": "ready", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:ready", - "documentId": "document", - "token": "token" - } - }, - { - "name": "initialize", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:initialize", - "documentId": "document", - "sessionId": "session", - "token": "token", - "state": { "search": "?period=7", "hash": "#daily" } - } - }, - { - "name": "request", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:request", - "documentId": "document", - "sessionId": "session", - "token": "token", - "id": "request", - "route": "data:query", - "payload": { "operation": "count", "input": {} } - } - }, - { - "name": "result", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:result", - "documentId": "document", - "sessionId": "session", - "token": "token", - "id": "request", - "response": { "count": 3 } - } - }, - { - "name": "error", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:error", - "documentId": "document", - "sessionId": "session", - "token": "token", - "id": "request", - "code": "forbidden", - "message": "Denied", - "requestId": "server-request" - } - }, - { - "name": "cancel", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:cancel", - "documentId": "document", - "sessionId": "session", - "token": "token", - "id": "request" - } - }, - { - "name": "disconnect", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "bridge:disconnect", - "documentId": "document", - "sessionId": "session", - "token": "token" - } - }, - { - "name": "scriptLoad", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "script:load", - "documentId": "document", - "sessionId": "session", - "token": "token", - "javascript": "app()" - } - }, - { - "name": "stateUpdate", - "from": "host", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "state:update", - "documentId": "document", - "sessionId": "session", - "token": "token", - "state": { "search": "?period=30", "hash": "#daily" } - } - }, - { - "name": "runtimeReady", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "runtime:ready", - "documentId": "document", - "sessionId": "session", - "token": "token" - } - }, - { - "name": "runtimeError", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "runtime:error", - "documentId": "document", - "sessionId": "session", - "token": "token" - } - }, - { - "name": "runtimeLog", - "from": "app", - "message": { - "channel": "altertable:data-app", - "version": 1, - "type": "runtime:log", - "documentId": "document", - "sessionId": "session", - "token": "token", - "payload": { - "method": "info", - "args": [ - "Completed", - { - "rows": 3 - } - ] - } - } - } -] diff --git a/tests/format.test.ts b/tests/format.test.ts deleted file mode 100644 index d261fcfe..00000000 --- a/tests/format.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from 'bun:test'; -import { - formatNumber, - formatCount, - formatPercent, - formatDateRange, -} from '@altertable/data-app/format'; -test('number formatting distinguishes counts and ratios', () => { - expect(formatNumber(12.345, { maximumFractionDigits: 2 })).toBe('12.35'); - expect(formatNumber(-0)).toBe('0'); - expect(formatCount(12_345)).toBe('12,345'); - expect(formatCount(12_345, { compact: true })).toBe('12.3K'); - expect(formatCount(12.5)).toBe('—'); - expect(formatPercent(0.116)).toBe('11.6%'); - expect(formatPercent(0.0012)).toBe('0.12%'); - expect(formatPercent(0.00002)).toBe('<0.01%'); - expect(formatPercent(null)).toBe('—'); -}); - -test('calendar ranges stay compact without hiding their year', () => { - expect(formatDateRange({ start: '2026-09-25', end: '2026-09-27' })).toBe( - 'Sep 25–27, 2026' - ); - expect(formatDateRange({ start: '2026-08-30', end: '2026-09-28' })).toBe( - 'Aug 30–Sep 28, 2026' - ); - expect(formatDateRange({ start: '2025-12-30', end: '2026-01-02' })).toBe( - 'Dec 30, 2025–Jan 2, 2026' - ); - expect(formatDateRange({ start: '2026-09-28', end: '2026-09-28' })).toBe( - 'Sep 28, 2026' - ); -}); diff --git a/tests/loading-content.test.tsx b/tests/loading-content.test.tsx deleted file mode 100644 index 3496d979..00000000 --- a/tests/loading-content.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { DataValue } from '@altertable/data-app/react/ui'; -import { VisualizationWidget } from '@altertable/data-app/react/ui'; -import { TableWidget } from '@altertable/data-app/react/ui'; -import { MetricWidget } from '@altertable/data-app/react/ui'; -import { expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { type WidgetEvidence } from '@altertable/data-app/react'; - -const evidence: WidgetEvidence = { id: 'orders', queryNames: ['orders'] }; - -test('metric loading preserves static text and reserves only the value', () => { - const html = renderToStaticMarkup( - - ); - expect(html).toContain('Orders in any status.'); - expect(html).toContain('>Orders'); - expect(html).toContain('altertable-metric-loading-value'); - expect(html).toContain('aria-busy="true"'); - expect(html).not.toContain('altertable-content-skeleton-label'); -}); - -test('bound chart loading keeps its heading without evaluating data', () => { - let renders = 0; - const html = renderToStaticMarkup( - { - throw new Error('No loading data'); - }} - emptyFallback={{ title: 'No orders' }} - skeleton={{ variant: 'ranking', rows: 3 }} - > - {() => { - renders++; - return 'ready'; - }} - - ); - expect(html).toContain('Orders per day'); - expect(html).toContain('Days without orders count as zero.'); - expect(html.match(/class="altertable-content-skeleton-row"/g)).toHaveLength( - 3 - ); - expect(html).not.toContain('Explore Orders per day'); - expect(html).not.toContain('No orders'); - expect(renders).toBe(0); -}); - -test('table loading preserves column headings and section attributes', () => { - const html = renderToStaticMarkup( - { - throw new Error('No loading data'); - }, - }, - ]} - rowKey={() => { - throw new Error('No loading data'); - }} - emptyFallback={{ title: 'No orders' }} - /> - ); - expect(html).toContain('id="orders-table"'); - expect(html).toContain('Orders by country'); - expect(html).toContain('All order statuses.'); - expect(html).toContain('scope="col">Country'); - expect(html.match(/class="altertable-skeleton"/g)).toHaveLength(2); - expect(html).not.toContain('No orders'); -}); - -test('inline readings retain static prose and render measured zero', () => { - let renders = 0; - function render( - reading: { loading: true } | { loading: false; value: number } - ) { - return renderToStaticMarkup( -

- Orders:{' '} - pending}> - {value => { - renders++; - return value; - }} - {' '} - in the last 7 days. -

- ); - } - expect(render({ loading: true })).toContain('pending'); - expect(renders).toBe(0); - const ready = render({ loading: false, value: 0 }); - expect(ready).toContain('>0'); - expect(ready).toContain('in the last 7 days.'); - expect(ready).not.toContain('pending'); - expect(renders).toBe(1); -}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts deleted file mode 100644 index 0626a92c..00000000 --- a/tests/logger.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { expect, test } from 'bun:test'; -import { createIframeTransport } from '@altertable/data-app/client'; -import { - attachDataAppBridge, - type DataAppLogger, -} from '@altertable/data-app/embed'; -import type { BridgeMessage } from '@/src/core/bridge'; - -function recordingLogger(calls: unknown[][]): DataAppLogger { - return { - log: (...args) => { - calls.push(['log', ...args]); - }, - info: (...args) => { - calls.push(['info', ...args]); - }, - warn: (...args) => { - calls.push(['warn', ...args]); - }, - error: (...args) => { - calls.push(['error', ...args]); - }, - }; -} - -function harness(logger?: DataAppLogger, mode: 'url' | 'bundle' = 'url') { - const hostEvents = new EventTarget(); - const frameEvents = new EventTarget(); - const queue: (() => void)[] = []; - const sent: BridgeMessage[] = []; - const requests: unknown[] = []; - function receive( - data: unknown, - origin = mode === 'bundle' ? 'null' : 'https://app.example', - source: unknown = target - ) { - hostEvents.dispatchEvent( - Object.assign(new Event('message'), { data, origin, source }) - ); - } - const parent = { - postMessage(value: BridgeMessage) { - const message = structuredClone(value); - sent.push(message); - queue.push(() => receive(message)); - }, - }; - const target = { - postMessage(value: BridgeMessage) { - const data = structuredClone(value); - queue.push(() => - frameEvents.dispatchEvent( - Object.assign(new Event('message'), { - data, - origin: 'https://shell.example', - source: parent, - }) - ) - ); - }, - }; - const host = attachDataAppBridge({ - iframe: Object.assign(new EventTarget(), { - contentWindow: target, - }) as unknown as HTMLIFrameElement, - connection: - mode === 'bundle' - ? { type: 'opaque', token: 'sandbox-token' } - : { type: 'origin', origin: 'https://app.example' }, - window: Object.assign(hostEvents, { - location: { search: '', hash: '' }, - }) as unknown as Window, - logger, - async onMessage(message) { - requests.push(message); - return null; - }, - }); - const frame = createIframeTransport({ - parentOrigin: 'https://shell.example', - mode, - window: Object.assign(frameEvents, { parent }) as unknown as Window, - }); - function flush() { - while (queue.length) queue.shift()!(); - } - function dispose() { - frame.dispose(); - host.dispose(); - } - return { host, frame, sent, requests, receive, flush, dispose }; -} - -test('iframe forwards log, info, warn and error to the shell without requests', () => { - const calls: unknown[][] = []; - const h = harness(recordingLogger(calls)); - try { - h.flush(); - calls.length = 0; - const logger = h.frame.logger; - logger.info(() => ['Completed', { rows: 3 }]); - const details = { rows: 1 }; - logger.log('Snapshot', details); - details.rows = 2; - logger.warn('Slow', () => ({ duration: 120 })); - logger.error(new Error('failed')); - expect(calls).toEqual([]); - h.flush(); - expect(calls).toEqual([ - ['info', 'Completed', { rows: 3 }], - ['log', 'Snapshot', { rows: 1 }], - ['warn', 'Slow', { duration: 120 }], - ['error', expect.any(Error)], - ]); - expect(h.requests).toEqual([]); - expect( - h.sent - .filter(message => message.type === 'runtime:log') - .every(message => !('id' in message)) - ).toBe(true); - } finally { - h.dispose(); - } -}); - -test('iframe logger stays callable as the host enables, replaces and removes logging', () => { - const first: unknown[][] = []; - const second: unknown[][] = []; - const h = harness(); - let evaluated = 0; - let stateNotifications = 0; - function lazy() { - evaluated++; - return 'entry'; - } - try { - const logger = h.frame.logger; - logger.info(lazy); - h.flush(); - h.frame.subscribe(() => { - stateNotifications++; - }); - logger.info(lazy); - expect(evaluated).toBe(0); - h.host.setLogger(recordingLogger(first)); - logger.info(lazy); - expect(evaluated).toBe(0); - expect(h.frame.logger).toBe(logger); - h.flush(); - logger.info(lazy); - h.flush(); - expect(first).toEqual([['info', 'entry']]); - expect(stateNotifications).toBe(1); - h.host.setLogger(recordingLogger(second)); - h.flush(); - logger.warn('replacement'); - h.flush(); - expect(second).toEqual([['warn', 'replacement']]); - expect(stateNotifications).toBe(1); - h.host.setLogger(undefined); - h.flush(); - expect(stateNotifications).toBe(2); - expect(h.frame.logger).toBe(logger); - logger.info(lazy); - expect(evaluated).toBe(1); - h.host.setLogger(recordingLogger(second)); - h.flush(); - expect(h.frame.logger).toBe(logger); - logger.info('restored'); - h.flush(); - expect(second).toEqual([ - ['warn', 'replacement'], - ['info', 'restored'], - ]); - expect(stateNotifications).toBe(3); - h.frame.dispose(); - expect(h.frame.logger).toBe(logger); - logger.info(lazy); - expect(evaluated).toBe(1); - } finally { - h.dispose(); - } -}); - -for (const mode of ['url', 'bundle'] as const) { - test(`${mode} shell rejects foreign, stale and malformed log notifications`, () => { - const calls: unknown[][] = []; - const h = harness(recordingLogger(calls), mode); - try { - h.flush(); - calls.length = 0; - h.sent.length = 0; - h.frame.logger.info('valid'); - h.flush(); - const message = h.sent.find(message => message.type === 'runtime:log')!; - h.receive(message, 'https://foreign.example'); - h.receive( - message, - mode === 'bundle' ? 'null' : 'https://app.example', - {} - ); - if (mode === 'bundle') h.receive({ ...message, token: 'stale' }); - h.receive({ ...message, sessionId: 'stale' }); - h.receive({ ...message, documentId: 'stale' }); - h.receive({ - ...message, - payload: { method: 'constructor', args: [] }, - }); - h.receive({ ...message, payload: { method: 'info', args: 'invalid' } }); - h.receive({ - ...message, - payload: { method: 'info', args: Array(129).fill(0) }, - }); - expect(calls).toEqual([['info', 'valid']]); - } finally { - h.dispose(); - } - }); -} - -test('lazy argument, serialization and host logger failures stay isolated', () => { - const calls: unknown[][] = []; - const sink = recordingLogger(calls); - sink.warn = () => { - throw new Error('sink failure'); - }; - const h = harness(sink); - try { - h.flush(); - calls.length = 0; - expect(() => - h.frame.logger.info(() => { - throw new Error('lazy failure'); - }) - ).not.toThrow(); - expect(() => h.frame.logger.info({ callback() {} })).not.toThrow(); - h.frame.logger.info(...Array(129).fill(0)); - h.frame.logger.info(() => Array(129).fill(0)); - h.frame.logger.warn('sink failure'); - h.frame.logger.info('still works'); - expect(() => h.flush()).not.toThrow(); - expect(calls).toEqual([['info', 'still works']]); - } finally { - h.dispose(); - } -}); - -test('requests that cannot be delivered do not emit outgoing message logs', async () => { - const calls: unknown[][] = []; - const h = harness(recordingLogger(calls)); - try { - h.flush(); - calls.length = 0; - h.sent.length = 0; - const error = await h.frame - .request({ route: 'test:echo', payload: { callback() {} } }) - .catch(error => error); - expect(error).toMatchObject({ code: 'invalid_payload' }); - h.flush(); - expect(calls).toEqual([]); - expect(h.sent).toEqual([]); - } finally { - h.dispose(); - } -}); - -for (const mode of ['url', 'bundle'] as const) { - test(`${mode} iframe logs queued queries, SQL requests and cancellations without logging its own notifications`, async () => { - const calls: unknown[][] = []; - const h = harness(recordingLogger(calls), mode); - const controller = new AbortController(); - try { - const query = h.frame.request({ - route: 'data:query', - payload: { operation: 'sales', input: { secret: 'private' } }, - }); - expect(calls).toEqual([]); - h.flush(); - await Promise.resolve(); - h.flush(); - expect(await query).toBeNull(); - const sql = h.frame - .request( - { - route: 'data:sql', - payload: { statement: 'SELECT private FROM sales', limit: 10 }, - }, - controller.signal - ) - .catch(error => error); - controller.abort(); - h.flush(); - expect(await sql).toBe(controller.signal.reason); - const sent = h.sent.filter( - message => - message.type === 'bridge:request' || message.type === 'bridge:cancel' - ); - expect( - calls.filter(call => - (call[2] as { type: string }).type.startsWith('bridge:') - ) - ).toEqual([ - [ - 'log', - 'Sending message to parent', - { - type: 'bridge:request', - id: sent[0]!.id, - route: 'data:query', - operation: 'sales', - }, - ], - [ - 'log', - 'Sending message to parent', - { - type: 'bridge:request', - id: sent[1]!.id, - route: 'data:sql', - }, - ], - [ - 'log', - 'Sending message to parent', - { type: 'bridge:cancel', id: sent[1]!.id }, - ], - ]); - h.frame.ready(); - h.frame.fail(); - h.flush(); - expect(calls.slice(-2)).toEqual([ - ['log', 'Sending message to parent', { type: 'runtime:ready' }], - ['log', 'Sending message to parent', { type: 'runtime:error' }], - ]); - expect( - calls.every( - call => (call[2] as { type: string }).type !== 'runtime:log' - ) - ).toBe(true); - expect(JSON.stringify(calls)).not.toContain('private'); - expect(JSON.stringify(calls)).not.toContain('sandbox-token'); - expect( - h.sent.filter(message => message.type === 'runtime:log') - ).toHaveLength(calls.length); - h.host.setLogger(undefined); - h.flush(); - const count = calls.length; - h.frame.ready(); - h.flush(); - expect(calls).toHaveLength(count); - expect( - h.sent.filter(message => message.type === 'runtime:log') - ).toHaveLength(count); - } finally { - h.dispose(); - } - }); -} diff --git a/tests/navigation.test.ts b/tests/navigation.test.ts deleted file mode 100644 index aba46e16..00000000 --- a/tests/navigation.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { expect, test } from 'bun:test'; -import { BRIDGE, type BridgeMessage } from '@/src/core/bridge'; -import { - createIframeTransport, - installDataAppTransport, - createDataAppNavigation, - getDataAppNavigation, -} from '@altertable/data-app/client'; - -function harness(mode: 'url' | 'bundle' = 'bundle') { - const sent: BridgeMessage[] = []; - const parent = { - postMessage(message: BridgeMessage) { - sent.push(message); - }, - }; - let href = - 'https://app.example/?__altertable_parent=https%3A%2F%2Fhost.example'; - const writes: string[] = []; - const frame = Object.assign(new EventTarget(), { - parent, - document: { title: 'Navigation fixture' }, - location: { - get href() { - return href; - }, - }, - history: { - replaceState(_data: unknown, _unused: string, url: URL) { - href = url.href; - writes.push(href); - }, - }, - }) as unknown as Window; - const bridge = createIframeTransport({ - parentOrigin: 'https://host.example', - mode, - window: frame, - }); - let documentId = ''; - - function receive( - message: Partial, - origin = 'https://host.example', - source: unknown = parent - ) { - frame.dispatchEvent( - Object.assign(new Event('message'), { - origin, - source, - data: { - channel: BRIDGE, - version: 1, - documentId, - sessionId: 'session', - token: 'token', - ...message, - }, - }) - ); - } - - receive({ type: 'bridge:connect', documentId: 'host' }); - documentId = sent.at(-1)!.documentId; - - return { bridge, frame, receive, sent, writes }; -} - -test('transport retains opaque host state without applying navigation', () => { - const { bridge, frame, receive, writes, sent } = harness('url'); - try { - const state = { search: '?period=last-30', hash: '#totals' }; - receive({ type: 'bridge:initialize', state }); - expect(bridge.snapshot()).toEqual(state); - expect(writes).toEqual([]); - expect( - sent.some( - message => - message.type === 'bridge:request' && - message.route === 'navigation:update' - ) - ).toBe(false); - const navigation = createDataAppNavigation({ - bridge, - window: frame, - }); - expect(writes).toHaveLength(1); - navigation.dispose(); - } finally { - bridge.dispose(); - } -}); - -test('late navigation attachment reads the latest authenticated host state', () => { - const { bridge, frame, receive, writes } = harness(); - try { - receive({ - type: 'bridge:initialize', - state: { search: '?period=last-30', hash: '#totals' }, - }); - receive({ - type: 'state:update', - state: { search: '?period=last-7', hash: '#daily' }, - }); - const navigation = createDataAppNavigation({ bridge, window: frame }); - expect(navigation.snapshot()).toEqual({ - search: '?period=last-7', - hash: '#daily', - }); - expect(writes).toEqual([]); - let notifications = 0; - const unsubscribe = navigation.subscribe(() => { - notifications++; - }); - receive({ - type: 'state:update', - token: 'stale', - state: { search: '?forged=1', hash: '' }, - }); - receive({ - type: 'state:update', - sessionId: 'stale', - state: { search: '?forged=1', hash: '' }, - }); - receive( - { type: 'state:update', state: { search: '?forged=1', hash: '' } }, - 'https://other.example' - ); - receive( - { type: 'state:update', state: { search: '?forged=1', hash: '' } }, - 'https://host.example', - {} - ); - receive({ - type: 'state:update', - state: { search: 'invalid', hash: '#daily' }, - }); - expect(notifications).toBe(0); - expect(navigation.snapshot().search).toBe('?period=last-7'); - receive({ - type: 'state:update', - state: { search: '?period=last-30', hash: '#totals' }, - }); - expect(notifications).toBe(1); - navigation.dispose(); - receive({ - type: 'state:update', - state: { search: '?period=today', hash: '' }, - }); - navigation.update({ search: '?disposed=1', hash: '' }); - expect(notifications).toBe(1); - expect(navigation.snapshot().search).toBe('?period=last-30'); - unsubscribe(); - } finally { - bridge.dispose(); - } -}); - -test('URL navigation preserves the parent marker and publishes through the shared transport', async () => { - const { bridge, frame, receive, sent, writes } = harness('url'); - const uninstall = installDataAppTransport(bridge, frame); - try { - receive({ - type: 'bridge:initialize', - state: { search: '?period=last-30', hash: '#totals' }, - }); - const navigation = getDataAppNavigation(frame)!; - expect(getDataAppNavigation(frame)).toBe(navigation); - expect(navigation.snapshot()).toEqual({ - search: '?period=last-30', - hash: '#totals', - }); - expect( - new URL(frame.location.href).searchParams.get('__altertable_parent') - ).toBe('https://host.example'); - const before = writes.length; - navigation.update({ search: '?period=last-7', hash: '#daily' }, 'push'); - expect(writes).toHaveLength(before + 1); - const request = sent.at(-1)!; - if (request.type !== 'bridge:request') throw new Error('Expected request.'); - expect(request.route).toBe('navigation:update'); - expect(request.payload).toEqual({ - search: '?period=last-7', - hash: '#daily', - title: 'Navigation fixture', - mode: 'push', - }); - receive({ type: 'bridge:result', id: request.id, response: null }); - await Promise.resolve(); - navigation.dispose(); - expect(getDataAppNavigation(frame)).not.toBe(navigation); - getDataAppNavigation(frame)?.dispose(); - } finally { - uninstall(); - } -}); diff --git a/tests/annotations.test.ts b/tests/public-api/annotations.test.ts similarity index 83% rename from tests/annotations.test.ts rename to tests/public-api/annotations.test.ts index 223356d9..9b1b73a8 100644 --- a/tests/annotations.test.ts +++ b/tests/public-api/annotations.test.ts @@ -1,5 +1,5 @@ import { createAnnotationClient } from '@altertable/data-app/client'; -import { expect, test } from 'bun:test'; +import { expect, test } from 'vitest'; import { annotationDraftRoute, annotationModeRoute, @@ -10,7 +10,6 @@ import { MessageRoutingError, type DataAppAnnotationDraft, } from '@altertable/data-app/contract'; -import { isDataAppPresentation } from '@/src/core/presentation'; const draft: DataAppAnnotationDraft = { id: 'feedback-1', target: { @@ -80,31 +79,6 @@ test('invalid and oversized feedback cannot reach the host handler', async () => } expect(calls).toBe(0); }); -test('hosts explicitly advertise annotation support and bounded pin state', () => { - expect(isDataAppPresentation({ surface: 'embedded', theme: 'dark' })).toBe( - true - ); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { - enabled: true, - targets: [{ id: '1', targetId: 'revenue', number: 1 }], - }, - }) - ).toBe(true); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { - enabled: true, - targets: [{ id: '1', targetId: 'revenue', number: -1 }], - }, - }) - ).toBe(false); -}); test('shell-controlled annotation mode accepts booleans and rejects invalid mode state', async () => { const modes: boolean[] = []; @@ -122,20 +96,7 @@ test('shell-controlled annotation mode accepts booleans and rejects invalid mode { signal: new AbortController().signal } ); expect(modes).toEqual([false]); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { enabled: true, active: true }, - }) - ).toBe(true); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { enabled: true, active: 'yes' }, - }) - ).toBe(false); + const failure = await router .dispatch( { route: 'annotation:mode', payload: { active: 'yes' } }, @@ -262,33 +223,6 @@ test('editor state and display settings preserve canonical pin data', async () = await client.reportAnnotationEditorState({ hasUnsavedChanges: true }); await client.reportAnnotationEditorState({ hasUnsavedChanges: false }); expect(dirty).toEqual([true, false]); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { - enabled: true, - pinsVisible: false, - showHint: false, - readOnly: true, - targets: [ - { - id: '1', - targetId: 'revenue', - number: 1, - comment: 'Compare last year', - }, - ], - }, - }) - ).toBe(true); - expect( - isDataAppPresentation({ - surface: 'embedded', - theme: 'dark', - annotations: { enabled: true, readOnly: 'yes' }, - }) - ).toBe(false); }); test('root captures validate PNG dimensions and preserve exact cursor anchors', () => { diff --git a/tests/appearance.types.ts b/tests/public-api/appearance.types.ts similarity index 100% rename from tests/appearance.types.ts rename to tests/public-api/appearance.types.ts diff --git a/tests/public-api/apps-annotations.test.ts b/tests/public-api/apps-annotations.test.ts new file mode 100644 index 00000000..14cbb8db --- /dev/null +++ b/tests/public-api/apps-annotations.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +test('a host without annotation capability preserves the normal reader toolbar', async ({ + page, +}) => { + await page.goto('/annotations-host'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('heading', { name: 'Revenue by month', exact: true }) + .isVisible() + ) + .toBe(true); + expect( + await app.getByRole('button', { name: 'Annotate', exact: true }).count() + ).toBe(0); +}); + +for (const failure of [false, true]) { + test(`a reader delivers screenshot-backed feedback through the opaque bridge${failure ? ' after retrying a rejected draft' : ''}`, async ({ + page, + }) => { + await page.goto( + `/annotations-host?annotations${failure ? '&annotation-error' : ''}` + ); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Annotate', exact: true }).click(); + const selection = app.getByRole('button', { + name: 'Annotation selection', + exact: true, + }); + await selection.press('ArrowRight'); + await selection.press('Enter'); + const comment = app.getByRole('textbox', { + name: 'Annotation text', + exact: true, + }); + await comment.fill('Compare with last year'); + await app + .getByRole('button', { name: 'Add annotation', exact: true }) + .click(); + if (failure) { + await expect + .poll(() => app.getByRole('alert').textContent()) + .toContain('Could not save annotation. Try again.'); + expect(await comment.inputValue()).toBe('Compare with last year'); + await page + .getByRole('button', { name: 'Allow annotations', exact: true }) + .click(); + await app + .getByRole('button', { name: 'Add annotation', exact: true }) + .click(); + } + const output = page.getByRole('status', { + name: 'Annotation drafts', + exact: true, + }); + await expect + .poll(async () => JSON.parse((await output.textContent()) || '[]').length) + .toBe(1); + const [draft] = JSON.parse((await output.textContent())!); + expect(draft.comment).toBe('Compare with last year'); + expect(draft.target.label).toBeTruthy(); + expect(draft.context.screenshot).toMatchObject({ + mimeType: 'image/png', + dataUrl: expect.stringMatching(/^data:image\/png;base64,/), + }); + expect(draft.context.screenshot.width).toBeGreaterThan(0); + expect(draft.context.screenshot.height).toBeGreaterThan(0); + await expect + .poll(() => + app + .getByRole('button', { name: 'Annotation 1', exact: true }) + .isVisible() + ) + .toBe(true); + await selection.press('Escape'); + await page + .getByRole('button', { name: 'Replace app', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('button', { name: 'Annotation 1', exact: true }) + .isVisible() + ) + .toBe(true); + }); +} + +test('feedback retains the displayed input while a newer filter is still loading', async ({ + page, +}) => { + await page.goto('/annotations-host?annotations&annotation-state'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('status') + .filter({ hasText: /Showing last-30/ }) + .isVisible() + ) + .toBe(true); + await app.getByRole('button', { name: 'Annotate', exact: true }).click(); + const selection = app.getByRole('button', { + name: 'Annotation selection', + exact: true, + }); + await selection.press('ArrowRight'); + await selection.press('Enter'); + await app + .getByRole('textbox', { name: 'Annotation text', exact: true }) + .fill('Keep this displayed period'); + await app + .getByRole('button', { name: 'Add annotation', exact: true }) + .click(); + const output = page.getByRole('status', { + name: 'Annotation drafts', + exact: true, + }); + await expect + .poll(async () => JSON.parse((await output.textContent()) || '[]').length) + .toBe(1); + const [draft] = JSON.parse((await output.textContent())!); + expect(draft.context.displayedInput).toEqual({ period: 'last-30' }); + expect(draft.context.view).toBe('updating'); + expect(draft.target.text).toContain('42'); +}); diff --git a/tests/public-api/apps-authoring.test.ts b/tests/public-api/apps-authoring.test.ts new file mode 100644 index 00000000..2d603bc9 --- /dev/null +++ b/tests/public-api/apps-authoring.test.ts @@ -0,0 +1,437 @@ +import { describe, expect } from 'vitest'; +import { test, localURL } from '@/tests/public-api/browser'; + +describe('authoring', () => { + const failure = { error: 'Fixture unavailable' }; + + test('single-file hosted example queries through the host and preserves displayed filters', async ({ + page, + }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + const requests: { statement: string; limit: number; host: boolean }[] = []; + let fail = false; + let release: (() => void) | undefined; + let gate = new Promise(resolve => { + release = resolve; + }); + await page.route('**/api/sql', async route => { + const payload = route.request().postDataJSON(); + requests.push({ + ...payload, + host: route.request().frame() === page.mainFrame(), + }); + await gate; + if (fail) await route.fulfill({ status: 503, json: failure }); + else await route.continue(); + }); + await page.goto('/starter-data-app'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('group', { name: 'Page actions', exact: true }) + .filter({ hasText: 'Loading data' }) + .isVisible() + ) + .toBe(true); + + await expect + .poll(() => + app + .getByRole('heading', { name: 'Sample counts', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('heading', { name: 'Total samples', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('region', { name: 'Total samples', exact: true }) + .getByText('Sum of the fixture counts in the selected groups.', { + exact: true, + }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('heading', { name: 'Counts by group', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app.getByRole('row', { name: 'Alpha 3', exact: true }).count() + ) + .toBe(0); + await expect + .poll(() => + app + .getByRole('button', { name: 'Present story', exact: true }) + .isDisabled() + ) + .toBe(true); + const stableActions = ['Explore data', 'Export CSV', 'Present story'].map( + name => app.getByRole('button', { name, exact: true }) + ); + await expect.poll(() => stableActions[1]!.isDisabled()).toBe(true); + + release!(); + gate = Promise.resolve(); + await expect + .poll(() => + app + .getByRole('row', { name: 'Alpha 3', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('row', { name: 'Beta 0', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + await expect.poll(() => stableActions[1]!.isEnabled()).toBe(true); + await expect.poll(() => stableActions[2]!.isEnabled()).toBe(true); + + const width = await app + .locator('html') + .evaluate(element => element.clientWidth); + expect(width).toBeLessThanOrEqual(page.viewportSize()!.width); + const queryCountBeforeStory = requests.length; + await app + .getByRole('button', { name: 'Present story', exact: true }) + .click(); + const story = app.getByRole('dialog'); + await expect + .poll(() => + story + .getByRole('heading', { name: 'Total samples: 3', exact: true }) + .isVisible() + ) + .toBe(true); + await story.getByRole('button', { name: 'Next step', exact: true }).click(); + await expect + .poll(() => + story + .getByRole('heading', { name: 'Counts by group', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + story + .getByRole('button', { name: 'Explore sources', exact: true }) + .isVisible() + ) + .toBe(true); + + await story + .getByRole('button', { name: 'Exit presentation', exact: true }) + .click(); + expect(requests.length).toBe(queryCountBeforeStory); + const group = app.getByRole('searchbox', { name: 'Group', exact: true }); + gate = new Promise(resolve => { + release = resolve; + }); + await group.fill('Beta'); + await expect + .poll(() => + app + .getByRole('main') + .getByText('Showing all groups', { exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('status') + .filter({ hasText: 'Showing all groups while loading group Beta…' }) + .isVisible() + ) + .toBe(true); + release!(); + gate = Promise.resolve(); + await expect + .poll(() => + app + .getByRole('main') + .getByText('Showing group Beta', { exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('row', { name: 'Alpha 3', exact: true }) + .filter({ visible: true }) + .count() + ) + .toBe(0); + fail = true; + await group.fill('Alpha'); + await expect + .poll(() => + app + .getByRole('main') + .getByText('Showing group Beta', { exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('alert') + .filter({ + hasText: + 'Couldn’t refresh. Showing group Beta while group Alpha is unavailable.', + }) + .isVisible() + ) + .toBe(true); + await app + .getByRole('button', { name: 'Present story', exact: true }) + .click(); + await expect + .poll(() => + story + .getByRole('heading', { name: 'Total samples: 0', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + story.evaluate(element => + (element.getAttribute('aria-describedby') ?? '') + .split(' ') + .map( + id => element.ownerDocument.getElementById(id)?.textContent ?? '' + ) + .join(' ') + ) + ) + .toBe('Demonstration values for group Beta.'); + await story + .getByRole('button', { name: 'Exit presentation', exact: true }) + .click(); + fail = false; + await app.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('main') + .getByText('Showing group Alpha', { exact: true }) + .isVisible() + ) + .toBe(true); + await group.fill('missing'); + await expect + .poll(() => + app + .getByRole('main') + .getByText('No matching groups', { exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('button', { name: 'Present story', exact: true }) + .isDisabled() + ) + .toBe(true); + await group.fill("O'Reilly"); + await expect.poll(() => requests.at(-1)?.statement).toContain("O''Reilly"); + await expect + .poll(() => + app + .getByRole('main') + .getByText('No matching groups', { exact: true }) + .isVisible() + ) + .toBe(true); + await group.fill(''); + await expect + .poll(() => + app + .getByRole('row', { name: 'Alpha 3', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + const before = requests.length; + await app + .getByRole('button', { name: 'Refresh data', exact: true }) + .click(); + await expect.poll(() => requests.length).toBeGreaterThan(before); + await expect + .poll(() => + app + .getByRole('row', { name: 'Beta 0', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + expect( + requests.every( + request => + request.host && + request.limit === 10 && + request.statement.includes('LIMIT 10') + ) + ).toBe(true); + await expect + .poll(() => + app + .getByRole('button', { name: 'Refresh data', exact: true }) + .isVisible() + ) + .toBe(true); + + expect(errors).toEqual([]); + }); + + test('hosted initial query error recovers by retry', async ({ page }) => { + let fail = true; + await page.route('**/api/sql', async route => { + if (fail) await route.fulfill({ status: 503, json: failure }); + else await route.continue(); + }); + await page.goto('/starter-data-app'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('alert') + .filter({ hasText: 'Couldn’t load results' }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('button', { name: 'Export CSV', exact: true }) + .isDisabled() + ) + .toBe(true); + await expect + .poll(() => + app + .getByRole('button', { name: 'Present story', exact: true }) + .isDisabled() + ) + .toBe(true); + fail = false; + await app.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('row', { name: 'Alpha 3', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + }); + + test('local starter mounts, queries its server, refreshes and retries a failed check', async ({ + page, + }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + let fail = false; + let release!: () => void; + let gate = new Promise(resolve => { + release = resolve; + }); + await page.route('**/api/data/connection', async route => { + await gate; + if (fail) + await route.fulfill({ + status: 503, + json: { + error: { + code: 'source_unavailable', + message: 'Fixture unavailable', + }, + }, + }); + else await route.continue(); + }); + await page.goto(localURL); + await expect + .poll(() => + page + .getByRole('heading', { name: 'Checking connection…', exact: true }) + .isVisible() + ) + .toBe(true); + release(); + gate = Promise.resolve(); + await expect + .poll(() => + page + .getByRole('heading', { name: 'Connected', exact: true }) + .isVisible() + ) + .toBe(true); + + fail = true; + await page + .getByRole('button', { name: 'Check connection', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('alert') + .filter({ + hasText: 'An earlier query succeeded, but the latest check failed.', + }) + .isVisible() + ) + .toBe(true); + fail = false; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('heading', { name: 'Connected', exact: true }) + .isVisible() + ) + .toBe(true); + fail = true; + await page.reload(); + await expect + .poll(() => + page + .getByRole('heading', { + name: 'Connection not verified', + exact: true, + }) + .isVisible() + ) + .toBe(true); + fail = false; + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('heading', { name: 'Connected', exact: true }) + .isVisible() + ) + .toBe(true); + + expect(errors).toEqual([]); + }); +}); diff --git a/tests/public-api/apps-embedding.test.ts b/tests/public-api/apps-embedding.test.ts new file mode 100644 index 00000000..2b979248 --- /dev/null +++ b/tests/public-api/apps-embedding.test.ts @@ -0,0 +1,510 @@ +import { expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +test('a host cancels timed-out queries and aborts old work when the iframe reloads', async ({ + page, +}) => { + await page.goto('/bridge-host?timeout=1'); + const app = page.frameLocator('iframe'); + await app + .getByRole('button', { name: 'Wait for query', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toBe('timeout'); + await expect + .poll(() => + page + .getByRole('status', { name: 'Cancelled requests', exact: true }) + .textContent() + ) + .toBe('1'); + await app + .getByRole('button', { name: 'Wait for query', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + .toBe('1'); + await page + .getByRole('button', { name: 'Replace iframe', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Cancelled requests', exact: true }) + .textContent() + ) + .toBe('2'); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); +}); + +test('iframe log delivery follows the current public host logger and can be disabled', async ({ + page, +}) => { + await page.goto('/bridge-host'); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + const entries: unknown[][] = [ + [1, 'log', 'plain'], + [1, 'info', 'completed', { rows: 3 }], + [1, 'warn', 'slow'], + [1, 'error', 'failed'], + ]; + await expect + .poll(() => + page.getByRole('status', { name: 'Host logs', exact: true }).textContent() + ) + .toBe(JSON.stringify(entries)); + await page + .getByRole('button', { name: 'Change handler', exact: true }) + .click(); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + entries.push(...entries.map(([, ...entry]) => [2, ...entry])); + await expect + .poll(() => + page.getByRole('status', { name: 'Host logs', exact: true }).textContent() + ) + .toBe(JSON.stringify(entries)); + await page + .getByRole('button', { name: 'Toggle logging', exact: true }) + .click(); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":2'); + expect( + await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent() + ).toBe(JSON.stringify(entries)); +}); + +test('a failing consumer logger cannot interrupt iframe queries', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/bridge-host'); + const app = page.frameLocator('iframe'); + await app + .getByRole('button', { name: 'Write failing logs', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toBe('App continued after logging'); + await page.getByRole('button', { name: 'Break logger', exact: true }).click(); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); + expect( + await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent() + ).toBe('[]'); + expect(errors).toEqual([]); + await page.getByRole('button', { name: 'Break logger', exact: true }).click(); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + await expect + .poll(async () => + JSON.parse( + (await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent())! + ) + ) + .toHaveLength(4); +}); + +test('foreign and malformed iframe log messages cannot invoke the host logger', async ({ + page, +}) => { + await page.addInitScript(() => { + window.addEventListener('message', event => { + if (event.data?.type === 'runtime:log') + Object.assign(window, { capturedLog: event.data }); + }); + }); + await page.goto('/bridge-host'); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + await expect + .poll(async () => + JSON.parse( + (await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent())! + ) + ) + .toHaveLength(4); + const original = await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent(); + await expect + .poll(() => + page.evaluate(() => Boolean(Reflect.get(window, 'capturedLog'))) + ) + .toBe(true); + const invocations = page.getByRole('status', { + name: 'Logger invocations', + exact: true, + }); + const initialCalls = Number(await invocations.textContent()); + const packet = await page.evaluate(() => Reflect.get(window, 'capturedLog')); + await page.evaluate(value => window.postMessage(value, '*'), packet); + const frame = page.frames().find(frame => frame !== page.mainFrame())!; + await frame.evaluate(value => { + parent.postMessage({ ...value, sessionId: 'old-session' }, '*'); + parent.postMessage( + { ...value, payload: { method: 'constructor', args: ['failed'] } }, + '*' + ); + parent.postMessage( + { ...value, payload: { method: 'error', args: 'failed' } }, + '*' + ); + }, packet); + // Valid logs follow the malformed packets from the same sender, proving delivery has drained. + await app.getByRole('button', { name: 'Write logs', exact: true }).click(); + await expect + .poll(async () => + JSON.parse( + (await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent())! + ) + ) + .toHaveLength(8); + expect(Number(await invocations.textContent())).toBe(initialCalls + 4); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); + expect( + JSON.parse( + (await page + .getByRole('status', { name: 'Host logs', exact: true }) + .textContent())! + ) + ).toEqual([...JSON.parse(original!), ...JSON.parse(original!)]); +}); + +test('an overloaded iframe rejects excess work, releases cancelled requests and settles pending work on disposal', async ({ + page, +}) => { + await page.goto('/bridge-host'); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Flood queries', exact: true }).click(); + await expect + .poll(async () => + Number( + await page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + ) + .toBeGreaterThan(0); + await app.getByRole('button', { name: 'Cancel query', exact: true }).click(); + await expect + .poll( + async () => + JSON.parse( + (await app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent()) || '[]' + ).length + ) + .toBe(200); + const results: string[] = JSON.parse( + (await app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent())! + ); + expect(results).toContain('bridge_busy'); + expect(results).toContain('AbortError'); + expect( + results.every(result => result === 'bridge_busy' || result === 'AbortError') + ).toBe(true); + await expect + .poll(() => + page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + .toBe('0'); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); + await app + .getByRole('button', { name: 'Wait for query', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + .toBe('1'); + await app + .getByRole('button', { name: 'Dispose transport', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toBe('bridge_closed'); + await expect + .poll(() => + page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + .toBe('0'); +}); + +for (const mode of ['bridge-host', 'bundle-host']) { + test(`the ${mode} correlates concurrent queries and cancels host work when its caller aborts`, async ({ + page, + }) => { + await page.goto(`/${mode}`); + const app = page.frameLocator('iframe'); + const result = app.getByRole('status', { + name: mode === 'bridge-host' ? 'Query result' : 'Transport result', + exact: true, + }); + await app + .getByRole('button', { name: 'Concurrent queries', exact: true }) + .click(); + await expect + .poll(async () => JSON.parse((await result.textContent()) || '[]')) + .toEqual( + Array.from({ length: 50 }, (_, index) => ({ + period: String(index), + version: 1, + })) + ); + await app + .getByRole('button', { name: 'Wait for query', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Pending requests', exact: true }) + .textContent() + ) + .toBe('1'); + await app + .getByRole('button', { name: 'Cancel query', exact: true }) + .click(); + await expect.poll(() => result.textContent()).toBe('AbortError'); + await expect + .poll(() => + page + .getByRole('status', { name: 'Cancelled requests', exact: true }) + .textContent() + ) + .toBe('1'); + }); + + test(`the ${mode} rejects messages from another window and from stale sessions`, async ({ + page, + }) => { + await page.addInitScript(() => { + window.addEventListener('message', event => { + if (event.data?.type === 'bridge:request') + Object.assign(window, { capturedRequest: event.data }); + }); + }); + await page.goto(`/${mode}`); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Echo requests', exact: true }) + .textContent() + ) + .toBe('1'); + const captured = await page.evaluate(() => + Reflect.get(window, 'capturedRequest') + ); + await page.evaluate( + packet => window.postMessage({ ...packet, id: 'foreign-source' }, '*'), + captured + ); + const frame = page.frames().find(frame => frame !== page.mainFrame())!; + await frame.evaluate(packet => { + parent.postMessage( + { ...packet, id: 'stale-session', sessionId: 'old-session' }, + '*' + ); + if (packet.token) + parent.postMessage( + { ...packet, id: 'stale-token', token: 'old-token' }, + '*' + ); + parent.postMessage( + { ...packet, id: 'malformed', payload: { period: 1 } }, + '*' + ); + }, captured); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Echo requests', exact: true }) + .textContent() + ) + .toBe('2'); + }); +} + +for (const source of ['bundle', 'url']) { + test(`an embedded ${source} app queries through the host and restores navigation after reload`, async ({ + page, + }) => { + const requests: string[] = []; + page.on('request', request => { + if (request.url().includes('/api/')) + requests.push(request.frame() === page.mainFrame() ? 'host' : 'app'); + }); + await page.goto( + `/bundle-host?${source === 'url' ? 'url=1&' : ''}period=last-30#totals` + ); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('status', { name: 'App location', exact: true }) + .textContent() + ) + .toContain('period=last-30'); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); + await page + .getByRole('button', { name: 'Change handler', exact: true }) + .click(); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":2'); + if (source === 'bundle') { + await app + .getByRole('button', { name: 'Data query', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('sql-query'); + await app + .getByRole('button', { name: 'Denied query', exact: true }) + .click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toBe('{"publicError":true,"code":"forbidden"}'); + expect(requests).toEqual(['host', 'host']); + } + await app.getByRole('button', { name: 'Last 7 days', exact: true }).click(); + await expect.poll(() => page.url()).toContain('period=last-7'); + await page.goBack(); + await expect + .poll(() => + app + .getByRole('status', { name: 'App location', exact: true }) + .textContent() + ) + .toContain('period=last-30'); + await page.goForward(); + await expect + .poll(() => + app + .getByRole('status', { name: 'App location', exact: true }) + .textContent() + ) + .toContain('period=last-7'); + const frame = page.frames().find(frame => frame !== page.mainFrame())!; + expect( + await frame.evaluate(() => { + try { + void parent.document; + return false; + } catch { + return true; + } + }) + ).toBe(true); + await frame.evaluate(() => location.reload()); + await expect + .poll(() => + app + .getByRole('status', { name: 'App location', exact: true }) + .textContent() + ) + .toContain('period=last-7'); + await app.getByRole('button', { name: 'Query', exact: true }).click(); + await expect + .poll(() => + app + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":2'); + }); +} diff --git a/tests/public-api/apps-export.test.ts b/tests/public-api/apps-export.test.ts new file mode 100644 index 00000000..296a72f5 --- /dev/null +++ b/tests/public-api/apps-export.test.ts @@ -0,0 +1,228 @@ +import { describe, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { unzipSync, strFromU8 } from 'fflate'; +import { test } from '@/tests/public-api/browser'; + +describe('export', () => { + async function contents(download: import('playwright').Download) { + const path = await download.path(); + expect(path).not.toBeNull(); + return readFile(path!, 'utf8'); + } + + test('standalone toolbar downloads a UTF-8 CSV with escaped cells', async ({ + page, + }) => { + await page.goto('/static'); + const downloaded = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export CSV', exact: true }).click(); + const download = await downloaded; + expect(download.suggestedFilename()).toBe('gallery.csv'); + expect(await contents(download)).toBe( + '\uFEFFName,Count\r\n"München, ""East""",0\r\n"Two\nlines",\r\n\'=1+1,-10\r\n\' +SUM(A1),false\r\n\'@SUM(A1),true\r\n' + ); + }); + + test('embedded toolbar exports displayed results through its host during updates and failures', async ({ + page, + }) => { + let release!: () => void; + let gate = new Promise(resolve => { + release = resolve; + }); + let fail = false; + await page.route('**/api/sql', async route => { + await gate; + if (fail) + await route.fulfill({ status: 503, json: { error: 'Unavailable' } }); + else await route.continue(); + }); + await page.goto('/starter-data-app'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('group', { name: 'Page actions', exact: true }) + .filter({ hasText: 'Loading data' }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => app.getByRole('button', { name: 'Export CSV' }).isDisabled()) + .toBe(true); + release(); + gate = Promise.resolve(); + await expect + .poll(() => + app + .getByRole('row', { name: 'Alpha 3', exact: true }) + .filter({ visible: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => page.locator('iframe').getAttribute('sandbox')) + .toBe('allow-scripts'); + + async function expectExport(filename: string, csv: string) { + const downloaded = page.waitForEvent('download'); + await app + .getByRole('button', { name: 'Export CSV', exact: true }) + .click(); + const download = await downloaded; + expect(download.suggestedFilename()).toBe(filename); + expect(await contents(download)).toBe(csv); + } + + await expectExport( + 'sample-counts-all-groups.csv', + '\uFEFFGroup,Sample count\r\nAlpha,3\r\nBeta,0\r\n' + ); + gate = new Promise(resolve => { + release = resolve; + }); + await app + .getByRole('searchbox', { name: 'Group', exact: true }) + .fill('Beta'); + await expect + .poll(() => + app + .getByRole('status') + .filter({ hasText: 'Showing all groups while loading group Beta…' }) + .isVisible() + ) + .toBe(true); + await expectExport( + 'sample-counts-all-groups.csv', + '\uFEFFGroup,Sample count\r\nAlpha,3\r\nBeta,0\r\n' + ); + release(); + gate = Promise.resolve(); + await expect + .poll(() => + app + .getByRole('main') + .getByText('Showing group Beta', { exact: true }) + .isVisible() + ) + .toBe(true); + fail = true; + await app + .getByRole('searchbox', { name: 'Group', exact: true }) + .fill('Alpha'); + await expect + .poll(() => + app + .getByRole('alert') + .filter({ + hasText: + 'Couldn’t refresh. Showing group Beta while group Alpha is unavailable.', + }) + .isVisible() + ) + .toBe(true); + await expectExport( + 'sample-counts-group-beta.csv', + '\uFEFFGroup,Sample count\r\nBeta,0\r\n' + ); + fail = false; + await app + .getByRole('searchbox', { name: 'Group', exact: true }) + .fill('missing'); + await expect + .poll(() => + app + .getByRole('main') + .getByText('No matching groups', { exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => app.getByRole('button', { name: 'Export CSV' }).isDisabled()) + .toBe(true); + }); + + test('embedded export failures stay visible and allow retry', async ({ + page, + }) => { + await page.goto('/starter-data-app?export-error'); + const app = page.frameLocator('iframe'); + const button = app.getByRole('button', { name: 'Export CSV', exact: true }); + await button.click(); + await expect + .poll(() => app.getByRole('alert').textContent()) + .toBe('Couldn’t export data.Try again'); + + await expect.poll(() => button.isEnabled()).toBe(true); + await page + .getByRole('button', { name: 'Allow exports', exact: true }) + .click(); + const downloaded = page.waitForEvent('download'); + await app.getByRole('button', { name: 'Try again', exact: true }).click(); + expect((await downloaded).suggestedFilename()).toBe( + 'sample-counts-all-groups.csv' + ); + await expect.poll(() => app.getByRole('alert').count()).toBe(0); + await expect.poll(() => button.isEnabled()).toBe(true); + }); + + for (const embedded of [false, true]) + test(`multiple datasets export CSVs and a ZIP ${embedded ? 'through the iframe host' : 'standalone'}`, async ({ + page, + }) => { + await page.goto(embedded ? '/bundle-host' : '/static?multiple-exports'); + const app = embedded ? page.frameLocator('iframe') : page; + const button = app.getByRole('button', { name: 'Export', exact: true }); + await button.focus(); + await expect + .poll(() => app.getByRole('tooltip').textContent()) + .toBe('Export data…'); + await button.press('ArrowDown'); + await expect + .poll(() => + app + .getByRole('option', { name: 'Export Counts CSV', exact: true }) + .evaluate( + element => element === element.ownerDocument.activeElement + ) + ) + .toBe(true); + let downloaded = page.waitForEvent('download'); + await app + .getByRole('option', { name: 'Export Summary CSV', exact: true }) + .click(); + let download = await downloaded; + expect(download.suggestedFilename()).toBe('Summary.csv'); + expect(await contents(download)).toBe('\uFEFFTotal\r\n0\r\n'); + await button.click(); + downloaded = page.waitForEvent('download'); + await app + .getByRole('option', { name: 'Export all ZIP', exact: true }) + .click(); + download = await downloaded; + expect(download.suggestedFilename()).toBe('gallery.zip'); + const files = unzipSync(await readFile((await download.path())!)); + expect(Object.keys(files)).toEqual(['Counts.csv', 'Summary.csv']); + expect(Array.from(files['Summary.csv']!.slice(0, 3))).toEqual([ + 239, 187, 191, + ]); + expect(strFromU8(files['Summary.csv']!)).toBe('Total\r\n0\r\n'); + await button.click(); + downloaded = page.waitForEvent('download'); + await app + .getByRole('option', { name: 'Export all ZIP', exact: true }) + .click(); + expect((await downloaded).suggestedFilename()).toBe('gallery.zip'); + await button.click(); + + await app.getByRole('listbox').press('Escape'); + await expect.poll(() => app.getByRole('listbox').count()).toBe(0); + await expect + .poll(() => + button.evaluate( + element => element === element.ownerDocument.activeElement + ) + ) + .toBe(true); + }); +}); diff --git a/tests/public-api/apps-presentation.test.ts b/tests/public-api/apps-presentation.test.ts new file mode 100644 index 00000000..9b447ff7 --- /dev/null +++ b/tests/public-api/apps-presentation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +describe('present', () => { + test('a hosted story enters fullscreen and restores the exploration on close', async ({ + page, + }) => { + await page.goto('/starter-data-app'); + const app = page.frameLocator('iframe'); + const launch = app.getByRole('button', { + name: 'Present story', + exact: true, + }); + await launch.click(); + await expect + .poll(() => page.locator('iframe:fullscreen').isVisible()) + .toBe(true); + await expect + .poll(() => app.locator('html:fullscreen').isVisible()) + .toBe(true); + const story = app.getByRole('dialog'); + await expect + .poll(() => + story.getByRole('heading', { name: 'Total samples: 3' }).isVisible() + ) + .toBe(true); + await expect + .poll(() => page.url()) + .toMatch(/present=1.*step=total-samples/); + await story.getByRole('button', { name: 'Next step', exact: true }).click(); + await expect + .poll(() => + story.getByRole('heading', { name: 'Counts by group' }).isVisible() + ) + .toBe(true); + + await story + .getByRole('button', { name: 'Exit presentation', exact: true }) + .click(); + await expect.poll(() => page.locator(':fullscreen').count()).toBe(0); + await expect.poll(() => story.count()).toBe(0); + await expect.poll(() => page.url()).not.toMatch(/present=1/); + await expect + .poll(() => + launch.evaluate( + element => element === element.ownerDocument.activeElement + ) + ) + .toBe(true); + }); + + test('a saved presentation opens without fullscreen or a user gesture', async ({ + page, + }) => { + await page.goto('/starter-data-app?present=1&step=counts-by-group'); + const app = page.frameLocator('iframe'); + await expect + .poll(() => + app + .getByRole('dialog') + .getByRole('heading', { name: 'Counts by group' }) + .isVisible() + ) + .toBe(true); + await expect.poll(() => app.locator(':fullscreen').count()).toBe(0); + await expect.poll(() => page.locator(':fullscreen').count()).toBe(0); + await page.keyboard.press('Escape'); + await expect.poll(() => app.getByRole('dialog').count()).toBe(0); + await expect.poll(() => page.url()).not.toMatch(/present=1/); + }); + + test('a host denying fullscreen still allows the story to open and close', async ({ + page, + }) => { + await page.goto('/starter-data-app?no-fullscreen=1'); + const app = page.frameLocator('iframe'); + await app + .getByRole('button', { name: 'Present story', exact: true }) + .click(); + const story = app.getByRole('dialog'); + await expect + .poll(() => + story.getByRole('heading', { name: 'Total samples: 3' }).isVisible() + ) + .toBe(true); + await expect.poll(() => page.locator(':fullscreen').count()).toBe(0); + await story + .getByRole('button', { name: 'Exit presentation', exact: true }) + .click(); + await expect.poll(() => story.count()).toBe(0); + await expect.poll(() => page.url()).not.toMatch(/present=1/); + }); +}); diff --git a/tests/public-api/apps-reports.test.ts b/tests/public-api/apps-reports.test.ts new file mode 100644 index 00000000..d30dc38c --- /dev/null +++ b/tests/public-api/apps-reports.test.ts @@ -0,0 +1,329 @@ +import { expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +for (const width of [375, 1280]) { + test(`a report remains readable at ${width}px while loading, empty, failed, ready and stale`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + let respond: ((data: number[] | null) => void) | undefined; + await page.route('**/layout-data/activity', async route => { + const data = await new Promise(resolve => { + respond = resolve; + }); + if (data === null) + await route.fulfill({ + status: 503, + json: { + error: { code: 'unavailable', message: 'Activity unavailable' }, + }, + }); + else + await route.fulfill({ + json: { + data, + requestId: 'activity', + queriedAt: '2026-10-07T00:00:00Z', + queryIds: [], + queries: [], + }, + }); + }); + async function readable() { + await expect + .poll(() => + page.getByRole('heading', { name: 'Activity overview' }).isVisible() + ) + .toBe(true); + const geometry = await page.getByRole('main').evaluate(element => { + const rect = element.getBoundingClientRect(); + const sections = element.querySelector( + '[aria-label="Report sections"]' + )!; + const children = [...sections.children].map(child => + child.getBoundingClientRect() + ); + return { + left: rect.left, + right: rect.right, + width: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + gaps: children + .slice(1) + .map((child, index) => child.top - children[index]!.bottom), + }; + }); + expect(geometry.left).toBeGreaterThan(0); + expect(geometry.right).toBeLessThan(geometry.width); + expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.width); + expect(geometry.gaps.every(gap => gap > 0)).toBe(true); + } + await page.goto('/layout'); + await expect + .poll(() => + page + .getByRole('group', { name: 'Page actions', exact: true }) + .filter({ hasText: 'Loading data' }) + .isVisible() + ) + .toBe(true); + await readable(); + await expect.poll(() => Boolean(respond)).toBe(true); + respond!(null); + await expect + .poll(() => + page + .getByRole('alert') + .filter({ hasText: 'Couldn’t load results' }) + .isVisible() + ) + .toBe(true); + await readable(); + respond = undefined; + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect.poll(() => Boolean(respond)).toBe(true); + respond!([]); + await expect + .poll(() => + page + .getByRole('main') + .getByText('No activity', { exact: true }) + .isVisible() + ) + .toBe(true); + await readable(); + respond = undefined; + await page + .getByRole('button', { name: 'Refresh data', exact: true }) + .click(); + await expect.poll(() => Boolean(respond)).toBe(true); + respond!([42]); + const cards = page.getByLabel('Activity cards'); + await expect.poll(() => cards.isVisible()).toBe(true); + await readable(); + const positions = await cards.evaluate(element => + [...element.children].map(child => { + const rect = child.getBoundingClientRect(); + return { + left: rect.left, + width: rect.width, + top: rect.top, + bottom: rect.bottom, + }; + }) + ); + expect(positions[1]!.top).toBeGreaterThan(positions[0]!.bottom); + expect(Math.abs(positions[0]!.left - positions[1]!.left)).toBeLessThan(1); + if (width === 375) + expect(Math.abs(positions[0]!.width - positions[1]!.width)).toBeLessThan( + 1 + ); + else expect(positions[0]!.width).toBeGreaterThan(positions[1]!.width); + respond = undefined; + await page + .getByRole('button', { name: 'Refresh data', exact: true }) + .click(); + await expect.poll(() => Boolean(respond)).toBe(true); + await readable(); + expect( + await cards + .getByRole('region', { name: 'Events', exact: true }) + .filter({ hasText: '42' }) + .count() + ).toBe(2); + respond!(null); + await expect + .poll(() => + page + .getByRole('alert') + .filter({ hasText: /Couldn’t refresh/ }) + .isVisible() + ) + .toBe(true); + await readable(); + expect( + await cards + .getByRole('region', { name: 'Events', exact: true }) + .filter({ hasText: '42' }) + .count() + ).toBe(2); + }); +} + +test('a report formats values, searches all loaded rows and paginates the visible table', async ({ + page, +}) => { + await page.goto('/static'); + await expect + .poll(() => + page + .getByRole('region', { name: 'Total events', exact: true }) + .textContent() + ) + .toContain('12,345'); + await expect + .poll(() => + page + .getByRole('region', { name: 'Conversion', exact: true }) + .textContent() + ) + .toContain('11.6%'); + await expect + .poll(() => + page + .getByRole('region', { name: 'Unavailable', exact: true }) + .textContent() + ) + .toContain('—'); + await expect + .poll(() => + page + .getByRole('row', { name: 'Measured zero 0', exact: true }) + .isVisible() + ) + .toBe(true); + const search = page.getByRole('searchbox', { name: 'Search groups' }); + await search.fill('Group 9'); + await expect + .poll(() => + page.getByRole('row', { name: 'Group 9 9', exact: true }).isVisible() + ) + .toBe(true); + await expect + .poll(() => + page.getByRole('row', { name: 'Measured zero 0', exact: true }).count() + ) + .toBe(0); + await search.fill('missing'); + await expect + .poll(() => + page + .getByRole('main') + .getByText('No matching events', { exact: true }) + .isVisible() + ) + .toBe(true); + await search.fill('cafe'); + await expect + .poll(() => + page + .getByRole('row', { name: 'Café 1234', exact: true }) + .isVisible() + ) + .toBe(true); + await search.fill(''); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await expect + .poll(() => + page.getByRole('row', { name: 'Group 9 9', exact: true }).isVisible() + ) + .toBe(true); + await expect + .poll(() => + page.getByRole('row', { name: 'Café
1234', exact: true }).count() + ) + .toBe(0); + await page + .getByRole('button', { name: 'Previous page', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('row', { name: 'Café
1234', exact: true }) + .isVisible() + ) + .toBe(true); +}); + +test('a report explicitly installs styles under CSP and can style another document independently', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/styles'); + const grid = page.getByLabel('Report grid'); + await expect.poll(() => grid.count()).toBe(1); + expect( + await grid.evaluate(element => getComputedStyle(element).display) + ).toBe('block'); + await page + .getByRole('button', { name: 'Style other report', exact: true }) + .click(); + const other = page.frameLocator('iframe').getByLabel('Report grid'); + await expect + .poll(() => other.evaluate(element => getComputedStyle(element).display)) + .toBe('grid'); + expect( + await grid.evaluate(element => getComputedStyle(element).display) + ).toBe('block'); + await page.getByRole('button', { name: 'Style report', exact: true }).click(); + await expect + .poll(() => grid.evaluate(element => getComputedStyle(element).display)) + .toBe('grid'); + await page.getByRole('button', { name: 'Style report', exact: true }).click(); + expect( + await page + .getByRole('status', { name: 'Stylesheet installation', exact: true }) + .textContent() + ).toBe('Reused stylesheet'); + expect(errors).toEqual([]); +}); + +test('a report formats missing values, precise ratios, localized numbers and calendar ranges without ambiguity', async ({ + page, +}) => { + await page.goto('/static'); + for (const row of [ + 'Rounded number 12.35', + 'Negative zero 0', + 'Compact count 12.3K', + 'Fractional count —', + 'Negative count —', + 'Nonfinite value —', + 'Custom missing label Unknown', + 'Small ratio 0.12%', + 'Tiny positive ratio <0.01%', + 'Tiny negative ratio >−0.01%', + 'Measured zero ratio 0%', + 'Currency $12.50', + 'Localized number 1.234,5', + 'Same month Sep 25–27, 2026', + 'Cross month Aug 30–Sep 28, 2026', + 'Cross year Dec 30, 2025–Jan 2, 2026', + 'Single day Sep 28, 2026', + 'Singular label 1 event', + 'Plural label 0 events', + ]) { + await expect + .poll(() => page.getByRole('row', { name: row, exact: true }).isVisible()) + .toBe(true); + } +}); + +test('an author can override public control tokens and apply and restore appearance', async ({ + page, +}) => { + await page.goto('/appearance'); + const action = page.getByRole('button', { + name: 'Custom action', + exact: true, + }); + await expect + .poll(() => action.evaluate(element => getComputedStyle(element).height)) + .toBe('60px'); + const originalColor = await action.evaluate( + element => getComputedStyle(element).color + ); + await page + .getByRole('button', { name: 'Apply appearance', exact: true }) + .click(); + await expect + .poll(() => action.evaluate(element => getComputedStyle(element).color)) + .toBe('rgb(51, 102, 153)'); + await page + .getByRole('button', { name: 'Restore appearance', exact: true }) + .click(); + await expect + .poll(() => action.evaluate(element => getComputedStyle(element).color)) + .toBe(originalColor); +}); diff --git a/tests/public-api/apps-runtime.test.ts b/tests/public-api/apps-runtime.test.ts new file mode 100644 index 00000000..cc25fcc9 --- /dev/null +++ b/tests/public-api/apps-runtime.test.ts @@ -0,0 +1,91 @@ +import { describe, expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +describe('runtime-errors', () => { + test('uncaught React rendering failures hide the app and offer retry', async ({ + page, + }) => { + await page.goto('/bundle-host'); + await page + .frameLocator('iframe') + .getByRole('button', { name: 'Crash render' }) + .click(); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); + await expect.poll(() => page.locator('iframe').isVisible()).toBe(false); + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect + .poll(() => + page + .frameLocator('iframe') + .getByRole('button', { name: 'Query', exact: true }) + .isVisible() + ) + .toBe(true); + }); + + for (const failure of ['syntax', 'execution']) { + test(`bundle ${failure} failures hide the app`, async ({ page }) => { + await page.goto(`/bundle-host?broken=1&${failure}=1`); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); + await expect.poll(() => page.locator('iframe').isVisible()).toBe(false); + }); + } + + test('delayed exceptions from the app bundle remain fatal', async ({ + page, + }) => { + await page.goto('/bundle-host'); + await page + .frameLocator('iframe') + .getByRole('button', { name: 'Crash callback' }) + .click(); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); + await expect.poll(() => page.locator('iframe').isVisible()).toBe(false); + }); + + test('unhandled rejections from the app bundle remain fatal', async ({ + page, + }) => { + await page.goto('/bundle-host'); + await page + .frameLocator('iframe') + .getByRole('button', { name: 'Reject promise' }) + .click(); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); + await expect.poll(() => page.locator('iframe').isVisible()).toBe(false); + }); +}); + +test('a failed embedded app can recover with a corrected bundle and bootstrap failures settle', async ({ + page, +}) => { + await page.goto('/bundle-host?broken=1'); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); + await page.getByRole('button', { name: 'Fix bundle', exact: true }).click(); + await page + .frameLocator('iframe') + .getByRole('button', { name: 'Query', exact: true }) + .click(); + await expect + .poll(() => + page + .frameLocator('iframe') + .getByRole('status', { name: 'Query result', exact: true }) + .textContent() + ) + .toContain('"version":1'); + await page.goto('/bundle-host?timeout=1'); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain('Could not load'); +}); diff --git a/tests/public-api/apps-views.test.ts b/tests/public-api/apps-views.test.ts new file mode 100644 index 00000000..d1e89a3d --- /dev/null +++ b/tests/public-api/apps-views.test.ts @@ -0,0 +1,419 @@ +import { expect } from 'vitest'; +import { test } from '@/tests/public-api/browser'; + +test('primary and independent sections retain their own displayed input and inspection evidence', async ({ + page, +}) => { + await page.goto('/declared-app'); + await page + .getByRole('button', { name: 'Resolve alpha 1', exact: true }) + .click(); + await page.getByRole('button', { name: 'Resolve beta', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Beta result', exact: true }) + .filter({ visible: true }) + .textContent() + ) + .toBe('1'); + await page.getByRole('searchbox', { name: 'Version', exact: true }).fill('2'); + await page.getByRole('button', { name: 'Show primary', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Alpha result', exact: true }) + .filter({ visible: true }) + .textContent() + ) + .toBe('1 for 1'); + await page.getByRole('button', { name: 'Explore Beta', exact: true }).click(); + const inspection = page.getByRole('dialog'); + await expect + .poll(() => inspection.textContent()) + .toContain('Only the beta section owns this glossary entry.'); + await inspection.getByRole('tab', { name: 'Queries', exact: true }).click(); + await expect.poll(() => inspection.textContent()).toContain('SELECT beta'); + await expect + .poll(() => inspection.textContent()) + .not.toContain('SELECT alpha'); + await page.keyboard.press('Escape'); + await page + .getByRole('button', { name: 'Resolve alpha 2', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Alpha result', exact: true }) + .filter({ visible: true }) + .textContent() + ) + .toBe('2 for 2'); +}); + +test('a bound visualization retains its selected view and displayed data while refreshing', async ({ + page, +}) => { + await page.goto('/declared-app'); + await page.getByRole('button', { name: 'Show primary', exact: true }).click(); + await page + .getByRole('button', { name: 'Resolve alpha 1', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { + name: 'Alpha visualization result', + exact: true, + }) + .filter({ visible: true }) + .textContent() + ) + .toBe('1'); + await page.getByRole('tab', { name: 'Doubled', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { + name: 'Alpha visualization result', + exact: true, + }) + .filter({ visible: true }) + .textContent() + ) + .toBe('2'); + await page.getByRole('searchbox', { name: 'Version', exact: true }).fill('2'); + await expect + .poll(() => + page + .getByRole('status', { + name: 'Alpha visualization result', + exact: true, + }) + .filter({ visible: true }) + .textContent() + ) + .toBe('2'); + await page + .getByRole('button', { name: 'Resolve alpha 2', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('status', { + name: 'Alpha visualization result', + exact: true, + }) + .filter({ visible: true }) + .textContent() + ) + .toBe('4'); +}); + +test('repeated public widgets share one inspection sheet and controlled inspection follows its owner', async ({ + page, +}) => { + await page.goto('/inspection-app?controlled'); + await page + .getByRole('button', { name: 'Explore Summary', exact: true }) + .click(); + await expect.poll(() => page.getByRole('dialog').count()).toBe(1); + await page.keyboard.press('Escape'); + await page + .getByRole('button', { name: 'Explore Repeated count', exact: true }) + .click(); + await expect.poll(() => page.getByRole('dialog').count()).toBe(1); + await page.keyboard.press('Escape'); + await page + .getByRole('button', { name: 'Controlled inspection', exact: true }) + .click(); + const requests = page.getByRole('status', { + name: 'Inspection requests', + exact: true, + }); + await expect.poll(() => requests.textContent()).toBe('[true]'); + expect(await page.getByRole('dialog').count()).toBe(0); + await page.getByRole('button', { name: 'Accept open', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('dialog') + .getByRole('heading', { name: 'Controlled', exact: true }) + .isVisible() + ) + .toBe(true); + await page.getByRole('button', { name: 'Close panel', exact: true }).click(); + await expect.poll(() => requests.textContent()).toBe('[true,false]'); + expect(await page.getByRole('dialog').count()).toBe(1); + await page.getByRole('button', { name: 'Accept close', exact: true }).click(); + await expect.poll(() => page.getByRole('dialog').count()).toBe(0); +}); + +test('calendar and dimension controls preserve comparisons, measured zero, and URL state', async ({ + page, +}) => { + await page.goto('/time-app'); + await expect + .poll(() => + page + .getByRole('main') + .getByText(/^Displayed period: Mar 4–6, 2026 UTC/) + .isVisible() + ) + .toBe(true); + await page.getByRole('button', { name: /^Choose dates/ }).click(); + await page + .getByRole('checkbox', { + name: 'Compare with previous period', + exact: true, + }) + .check(); + await page.keyboard.press('Escape'); + await expect.poll(() => page.url()).toContain('compare=previous'); + await page.getByRole('button', { name: 'Region: All', exact: true }).click(); + await page.getByRole('option', { name: 'Europe', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Measured events', exact: true }) + .filter({ hasText: 'Measured events: 0' }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + page + .getByRole('main') + .getByText(/^Displayed period: Mar 4–6, 2026 UTC/) + .isVisible() + ) + .toBe(true); + const selected = page.url(); + await page.reload(); + await expect + .poll(() => + page + .getByRole('button', { name: 'Region: Europe', exact: true }) + .isVisible() + ) + .toBe(true); + await expect.poll(() => page.url()).toBe(selected); + await page.goto('/time-app?start=invalid&end=invalid®ion=forged'); + await expect + .poll(() => + page + .getByRole('main') + .getByText(/^Displayed period: Mar 4–6, 2026 UTC/) + .isVisible() + ) + .toBe(true); +}); + +test('separate data clients isolate their results and facet choices when an app switches clients', async ({ + page, +}) => { + await page.goto('/clients'); + const selected = page.getByRole('region', { + name: 'Selected client', + exact: true, + }); + const independent = page.getByRole('region', { + name: 'Independent client', + exact: true, + }); + await page.getByRole('button', { name: 'Resolve A', exact: true }).click(); + await expect + .poll(() => + selected + .getByRole('status', { name: 'A count', exact: true }) + .filter({ hasText: 'A: 11' }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + independent.getByRole('status', { name: 'A count', exact: true }).count() + ) + .toBe(0); + await page + .getByRole('button', { name: 'Category: All', exact: true }) + .click(); + await expect + .poll(() => + page.getByRole('option', { name: 'A category', exact: true }).isVisible() + ) + .toBe(true); + await expect + .poll(() => + page.getByRole('option', { name: 'B category', exact: true }).count() + ) + .toBe(0); + await page + .getByRole('searchbox', { name: 'Search category values', exact: true }) + .press('Escape'); + await page.keyboard.press('Escape'); + await expect + .poll(() => + page + .getByRole('dialog', { name: 'Category options', exact: true }) + .count() + ) + .toBe(0); + await page + .getByRole('button', { name: 'Switch client', exact: true }) + .click(); + await expect + .poll(() => + selected.getByRole('status', { name: 'A count', exact: true }).count() + ) + .toBe(0); + await page.getByRole('button', { name: 'Resolve B', exact: true }).click(); + await expect + .poll(() => + selected + .getByRole('status', { name: 'B count', exact: true }) + .filter({ hasText: 'B: 22' }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + independent + .getByRole('status', { name: 'B count', exact: true }) + .filter({ hasText: 'B: 22' }) + .isVisible() + ) + .toBe(true); + await page + .getByRole('button', { name: 'Category: All', exact: true }) + .click(); + await expect + .poll(() => + page.getByRole('option', { name: 'B category', exact: true }).isVisible() + ) + .toBe(true); + await expect + .poll(() => + page.getByRole('option', { name: 'A category', exact: true }).count() + ) + .toBe(0); +}); + +for (const binding of [ + 'dataset', + 'metric', + 'export', + 'story-dataset', + 'story-metric', +]) { + test(`a report rejects another same-shaped view's ${binding} binding`, async ({ + page, + }) => { + await page.goto(`/ownership?binding=${binding}`); + await expect + .poll(() => page.getByRole('alert').textContent()) + .toContain( + binding.startsWith('story') + ? 'Story evidence must belong to the story view.' + : 'A binding requires the displayed source from its own view.' + ); + expect( + await page + .getByRole('button', { name: 'Present story', exact: true }) + .count() + ).toBe(0); + expect( + await page + .getByRole('button', { name: 'Export CSV', exact: true }) + .count() + ).toBe(0); + }); +} + +test('a report can display, export and present bindings from its own view', async ({ + page, +}) => { + await page.goto('/ownership'); + await expect + .poll(() => page.getByRole('row', { name: '7', exact: true }).isVisible()) + .toBe(true); + await expect + .poll(() => + page.getByRole('region', { name: 'Count', exact: true }).textContent() + ) + .toContain('7'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export CSV', exact: true }).click(); + expect((await download).suggestedFilename()).toMatch(/\.csv$/); + await page + .getByRole('button', { name: 'Present story', exact: true }) + .click(); + await expect + .poll(() => + page + .getByRole('dialog') + .getByRole('heading', { name: 'Measured count', exact: true }) + .isVisible() + ) + .toBe(true); + expect(await page.getByRole('alert').count()).toBe(0); +}); + +test('a phone reader can select filters, compare periods and inspect results by touch', async ({ + mobilePage: page, +}) => { + await page.goto('/time-app'); + await page.getByRole('button', { name: 'Region: All', exact: true }).tap(); + await page.getByRole('option', { name: 'Europe', exact: true }).tap(); + await expect + .poll(() => + page + .getByRole('status', { name: 'Measured events', exact: true }) + .textContent() + ) + .toBe('Measured events: 0'); + await page.getByRole('button', { name: /^Choose dates/ }).tap(); + await page + .getByRole('checkbox', { + name: 'Compare with previous period', + exact: true, + }) + .tap(); + await expect.poll(() => page.url()).toContain('compare=previous'); + await page.touchscreen.tap(8, 8); + await expect.poll(() => page.getByRole('dialog').count()).toBe(0); + await page.getByRole('button', { name: 'Explore Events', exact: true }).tap(); + await expect + .poll(() => + page.getByRole('dialog', { name: 'Events', exact: true }).isVisible() + ) + .toBe(true); + await page.getByRole('button', { name: 'Close panel', exact: true }).tap(); + await expect.poll(() => page.getByRole('dialog').count()).toBe(0); + const selected = page.url(); + await page.reload(); + await expect + .poll(() => + page + .getByRole('button', { name: 'Region: Europe', exact: true }) + .isVisible() + ) + .toBe(true); + await expect + .poll(() => + page + .getByRole('status', { name: 'Measured events', exact: true }) + .textContent() + ) + .toBe('Measured events: 0'); + expect(page.url()).toBe(selected); + await page.getByRole('button', { name: /^Choose dates/ }).tap(); + expect( + await page + .getByRole('checkbox', { + name: 'Compare with previous period', + exact: true, + }) + .isChecked() + ).toBe(true); +}); diff --git a/tests/authoring-entry.types.tsx b/tests/public-api/authoring-entry.types.tsx similarity index 99% rename from tests/authoring-entry.types.tsx rename to tests/public-api/authoring-entry.types.tsx index 80d424cf..e9a74c86 100644 --- a/tests/authoring-entry.types.tsx +++ b/tests/public-api/authoring-entry.types.tsx @@ -1,4 +1,4 @@ -import { createDataContext as registerContext } from '@/src/react/ui/data-context'; +import { createDataContext as registerContext } from '@altertable/data-app/react'; import * as authoring from '@altertable/data-app/react'; import { createDataHooks, diff --git a/tests/public-api/browser.ts b/tests/public-api/browser.ts new file mode 100644 index 00000000..36c68137 --- /dev/null +++ b/tests/public-api/browser.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process'; +import { chromium, type Browser, type Page } from 'playwright'; +import { afterAll, beforeAll, vi, test as vitestTest } from 'vitest'; + +let browser: Browser; +let baseURL: string; +export let localURL: string; +let server: ReturnType; +let output = ''; +beforeAll(async () => { + server = spawn('bun', ['tests/public-api/fixtures/server.ts'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout?.on('data', chunk => { + output += chunk; + }); + server.stderr?.on('data', chunk => { + output += chunk; + }); + await vi.waitFor( + () => { + if (server.exitCode !== null) throw new Error(output); + const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); + if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); + ({ baseURL, localURL } = JSON.parse(ready[1]!)); + }, + { timeout: 30000 } + ); + browser = await chromium.launch(); +}, 30000); + +afterAll(async () => { + await browser?.close(); + if (server && server.exitCode === null && server.signalCode === null) { + const exited = new Promise(resolve => + server.once('exit', () => resolve()) + ); + server.kill(); + await exited; + } +}); + +export const test = vitestTest.extend<{ page: Page; mobilePage: Page }>({ + // eslint-disable-next-line no-empty-pattern -- Vitest fixture dependencies use destructuring. + page: async ({}, use) => { + const context = await browser.newContext({ + baseURL, + viewport: { width: 1280, height: 900 }, + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + try { + await use(page); + } finally { + await context.close(); + } + }, + // eslint-disable-next-line no-empty-pattern -- Vitest fixture dependencies use destructuring. + mobilePage: async ({}, use) => { + const context = await browser.newContext({ + baseURL, + viewport: { width: 375, height: 812 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 3, + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + try { + await use(page); + } finally { + await context.close(); + } + }, +}); diff --git a/tests/client.types.ts b/tests/public-api/client.types.ts similarity index 100% rename from tests/client.types.ts rename to tests/public-api/client.types.ts diff --git a/tests/public-api/contracts.test.ts b/tests/public-api/contracts.test.ts new file mode 100644 index 00000000..134a855a --- /dev/null +++ b/tests/public-api/contracts.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from 'vitest'; +import { createDataClient } from '@altertable/data-app/client'; +import { + createDataHooks, + createDataContext, + textVariable, +} from '@altertable/data-app/react'; +import { + defineOperation, + defineDateRangeContract, + dimensionFilter, + parseDimensionSelection, + dimensionPredicate, + parseCount, + type DateRangeRequest, + type DimensionSelection, +} from '@altertable/data-app/contract'; + +test('a calendar and dimension declaration validates and escapes the input used by an operation', async () => { + const calendar = defineDateRangeContract({ + minDate: '2026-02-27', + maxDate: '2026-03-31', + maxRangeDays: 7, + timeZone: 'UTC', + }); + const region = dimensionFilter({ + key: 'region', + label: 'Region', + valueType: 'string', + selection: 'multiple', + allowMissing: true, + options: [ + { value: "O'Brien", label: 'Named region' }, + { value: 'null', label: 'Literal null' }, + ], + }); + const input: { + period: DateRangeRequest; + region: DimensionSelection; + } = { + period: calendar.request({ start: '2026-03-04', end: '2026-03-06' }, true), + region: { + kind: 'include' as const, + members: [ + { kind: 'value' as const, value: "O'Brien" }, + { kind: 'missing' as const }, + ], + }, + }; + const statements: string[] = []; + const operations = { + activity: defineOperation({ + input(value: unknown) { + const request = value as typeof input; + return { + period: calendar.parseRequest(request.period), + region: parseDimensionSelection(request.region, region), + }; + }, + output: parseCount, + checks: [input], + queryNames: { activity: 'activity' }, + policy: { maxQueryRows: 1, maxDurationMs: 1000, exposeSql: true }, + async run({ query }, selected) { + const result = await query( + 'activity', + `SELECT count FROM activity WHERE ${dimensionPredicate('region', selected.region, ['region'])}` + ); + return parseCount(result.rows[0]![0]); + }, + }), + }; + const client = createDataClient({ + operations, + lakehouse: { + async queryAll(statement) { + statements.push(statement); + return { columns: [{ name: 'count' }], rows: [[0]] }; + }, + }, + }); + const response = await client.query('activity', input); + expect(response.data).toBe(0); + expect(response.input.period.comparison).toEqual({ + start: '2026-03-01', + end: '2026-03-03', + }); + expect(statements).toEqual([ + "SELECT count FROM activity WHERE (region IN ('O''Brien') OR region IS NULL)", + ]); + expect( + region.read( + new URLSearchParams(region.write(input.region) as Record) + ) + ).toEqual(input.region); + for (const period of [ + { range: { start: '2026-02-30', end: '2026-03-02' }, comparison: null }, + { range: { start: '2026-03-01', end: '2026-03-09' }, comparison: null }, + { + range: input.period.range, + comparison: { start: '2026-03-02', end: '2026-03-03' }, + }, + ]) + await expect( + client.query('activity', { ...input, period }) + ).rejects.toMatchObject({ code: 'invalid_input' }); + await expect( + client.query('activity', { + ...input, + region: { + kind: 'include', + members: [{ kind: 'value', value: 'unknown' }], + }, + }) + ).rejects.toMatchObject({ code: 'invalid_input' }); + expect(statements).toHaveLength(1); +}); + +test('authoring a view rejects invalid datasets and unregistered evidence', () => { + const context = createDataContext({ rows: 'rows' })({ + description: 'Rows', + glossary: { + count: { + term: 'Count', + definition: 'Measured rows', + queryNames: ['rows'], + }, + }, + }); + const hooks = createDataHooks( + createDataClient({ + operations: { + rows: defineOperation({ + input: value => value as { group: string }, + output: parseCount, + checks: [{ group: '' }], + policy: { maxQueryRows: 1, maxDurationMs: 1000, exposeSql: false }, + async run() { + return 1; + }, + }), + }, + lakehouse: { + async queryAll() { + return { columns: [], rows: [] }; + }, + }, + }) + ); + const view = hooks.defineDataView({ + dataContext: context, + operation: 'rows', + variables: { group: textVariable({ key: 'group' }) }, + describeInput: input => input.group, + isEmpty: () => false, + emptyFallback: { title: 'Empty' }, + }); + expect(() => + view.dataset({ + name: 'Rows', + select: count => [count], + rowKey: row => row, + columns: { count: { value: row => row } }, + evidence: { id: 'rows', queryNames: ['unknown' as 'rows'] }, + }) + ).toThrow('Unknown query'); + expect(() => + view.dataset({ + name: 'Rows', + select: count => [count], + rowKey: row => row, + columns: {}, + evidence: { id: 'rows', queryNames: ['rows'] }, + }) + ).toThrow(); +}); diff --git a/tests/public-api/fixtures/annotation-state-app.tsx b/tests/public-api/fixtures/annotation-state-app.tsx new file mode 100644 index 00000000..6f71f05f --- /dev/null +++ b/tests/public-api/fixtures/annotation-state-app.tsx @@ -0,0 +1,119 @@ +import { useEffect } from 'react'; +import { + createDataClient, + getDataAppNavigation, +} from '@altertable/data-app/client'; +import type { DataOperation } from '@altertable/data-app/contract'; +import { + DataApp, + DataSection, + MetricWidget, + createDataHooks, + createDataContext, + injectDataAppStyles, + mountDataApp, + textVariable, +} from '@altertable/data-app/react'; + +type PeriodInput = { period: string }; + +const config = { + title: 'Displayed context', + scope: { organization: 'test', environment: 'prod' }, + appearance: { theme: 'system' as const }, +}; +function changePeriod(period: string) { + const navigation = getDataAppNavigation()!; + navigation.update({ ...navigation.snapshot(), search: `?period=${period}` }); +} +const client = createDataClient<{ + counts: DataOperation; +}>({ + transport(_name, input) { + const { period } = input as PeriodInput; + if (period === 'last-7') return new Promise(() => {}); + return Promise.resolve({ + status: 200, + body: { + data: period === 'last-365' ? 84 : 42, + requestId: period, + queriedAt: '2026-10-07T00:00:00Z', + queryIds: [], + queries: [], + }, + }); + }, +}); +const context = createDataContext({ counts: 'counts' })({ + description: 'Displayed context', + glossary: { + revenue: { + term: 'Revenue', + definition: 'Displayed count', + queryNames: ['counts'], + }, + }, +}); +const { defineDataView } = createDataHooks(client); +const view = defineDataView({ + dataContext: context, + operation: 'counts', + variables: { + period: textVariable({ + key: 'period', + label: 'Period', + defaultValue: 'last-30', + }), + }, + describeInput: input => input.period, + isEmpty: () => false, + emptyFallback: { title: 'No results' }, +}); +const metric = view.metric( + { + id: 'revenue', + glossaryId: 'revenue', + label: 'Revenue', + format: { kind: 'count' }, + }, + data => ({ current: data }) +); +const dataset = view.dataset({ + name: 'Counts', + select: value => [{ value }], + rowKey: () => 'count', + columns: { count: { value: row => row.value } }, + evidence: { id: 'counts', glossaryIds: ['revenue'] }, +}); +type AdvancePeriodProps = { period?: string }; +function AdvancePeriod({ period }: AdvancePeriodProps) { + useEffect(() => { + if (period === 'last-30') changePeriod('last-7'); + }, [period]); + return null; +} +const content = view.content(source => ( + <> + + + +)); +function App() { + return ( + []} + datasets={[dataset]} + toolbarActions={ + + } + > + + + ); +} +injectDataAppStyles(); +mountDataApp({ config, component: App }); diff --git a/tests/public-api/fixtures/annotations-app.tsx b/tests/public-api/fixtures/annotations-app.tsx new file mode 100644 index 00000000..6251aabc --- /dev/null +++ b/tests/public-api/fixtures/annotations-app.tsx @@ -0,0 +1,151 @@ +import { injectDataAppStyles } from '@altertable/data-app/react'; +import type { DataAppConfig } from '@altertable/data-app/config'; +import { connectionCheck } from '@altertable/data-app/contract'; +import { useState } from 'react'; +import { + createMessageClient, + createDataClient, + getDataAppTransport, + getDataAppNavigation, + DataAppError, +} from '@altertable/data-app/client'; +import { + DataApp, + DataWidget, + MetricWidget, + useAppVariables, +} from '@altertable/data-app/react/ui'; +import { AnnotationTarget } from '@altertable/data-app/react'; +import { mountDataApp, textVariable } from '@altertable/data-app/react'; +import { bridgeRoutes } from '@/tests/public-api/fixtures/bridge-routes'; +const config: DataAppConfig = { + title: 'Embedded report', + scope: { organization: 'test', environment: 'prod' }, + appearance: { theme: 'system' }, +}; +const bridge = getDataAppTransport()!; +const data = createDataClient({ + operations: { connection: connectionCheck() }, +}); +const messages = createMessageClient(bridgeRoutes, bridge.request); +const variables = { period: textVariable({ key: 'period', history: 'push' }) }; + +function App() { + const [crashed, setCrashed] = useState(false); + const state = useAppVariables(variables); + const search = state.values.period ? `period=${state.values.period}` : ''; + const [result, setResult] = useState(''); + if (crashed) throw new Error('Uncaught render failure'); + + return ( + Custom toolbar action} + footerActions={} + > + + + + + +

Revenue is growing.

+
+ + + +

+ {search} + {getDataAppNavigation()!.snapshot().hash} +

+

{result}

+ + + + + +
+ ); +} + +document.body.dataset.executions = String( + Number(document.body.dataset.executions ?? 0) + 1 +); +injectDataAppStyles(); + +mountDataApp({ + config, + component: App, +}); diff --git a/tests/public-api/fixtures/annotations-host.html b/tests/public-api/fixtures/annotations-host.html new file mode 100644 index 00000000..52b87a75 --- /dev/null +++ b/tests/public-api/fixtures/annotations-host.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/tests/public-api/fixtures/annotations-host.tsx b/tests/public-api/fixtures/annotations-host.tsx new file mode 100644 index 00000000..d2b7d23b --- /dev/null +++ b/tests/public-api/fixtures/annotations-host.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { DataAppBridge } from '@altertable/data-app/react/embed'; +import { + createMessageRouter, + annotationDraftRoute, + annotationUpdateRoute, + annotationModeRoute, + annotationEditorStateRoute, + MessageRoutingError, + type DataAppAnnotationDraft, +} from '@altertable/data-app/contract'; +const params = new URLSearchParams(location.search); +const javascript = await fetch( + params.has('annotation-state') + ? '/__test/annotation-state' + : '/__test/annotations' +).then(response => response.text()); +function Host() { + const [drafts, setDrafts] = useState([]); + const [failed, setFailed] = useState(params.has('annotation-error')); + const [version, setVersion] = useState(1); + const router = createMessageRouter( + { + 'annotation:draft': annotationDraftRoute, + 'annotation:update': annotationUpdateRoute, + 'annotation:mode': annotationModeRoute, + 'annotation:editor': annotationEditorStateRoute, + }, + { + 'annotation:draft'(draft) { + if (failed) + throw new MessageRoutingError( + 'unavailable', + 'Could not save annotation. Try again.' + ); + setDrafts(previous => [...previous, draft]); + return null; + }, + 'annotation:update'({ id, comment }) { + setDrafts(previous => + previous.map(draft => + draft.id === id ? { ...draft, comment } : draft + ) + ); + return null; + }, + 'annotation:mode'() { + return null; + }, + 'annotation:editor'() { + return null; + }, + } + ); + return ( + <> + {JSON.stringify(drafts)} + + + ({ + id: draft.id, + targetId: draft.target.id, + number: index + 1, + comment: draft.comment, + anchor: draft.context.anchor, + region: draft.context.region, + })), + }, + } + : {}), + }} + onMessage={router.dispatch} + /> + + ); +} +createRoot(document.getElementById('root')!).render(); diff --git a/tests/public-api/fixtures/appearance.html b/tests/public-api/fixtures/appearance.html new file mode 100644 index 00000000..c3df539e --- /dev/null +++ b/tests/public-api/fixtures/appearance.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/tests/public-api/fixtures/appearance.tsx b/tests/public-api/fixtures/appearance.tsx new file mode 100644 index 00000000..862789b8 --- /dev/null +++ b/tests/public-api/fixtures/appearance.tsx @@ -0,0 +1,36 @@ +import { createRoot } from 'react-dom/client'; +import { applyAppearance } from '@altertable/data-app/appearance'; +import { + Button, + injectDataAppStyles, + type DataAppStyle, +} from '@altertable/data-app/react'; +let cleanup: (() => void) | undefined; +const actionStyle: DataAppStyle = { + '--atbl-control-height': '60px', + color: 'var(--atbl-accent)', +}; +function App() { + return ( +
+

Appearance consumer

+ + + +
+ ); +} +injectDataAppStyles(); +createRoot(document.getElementById('root')!).render(); diff --git a/tests/public-api/fixtures/bridge-frame.html b/tests/public-api/fixtures/bridge-frame.html new file mode 100644 index 00000000..b04fd8a4 --- /dev/null +++ b/tests/public-api/fixtures/bridge-frame.html @@ -0,0 +1,13 @@ + + + + + Embedded app + + + + + + diff --git a/tests/public-api/fixtures/bridge-frame.ts b/tests/public-api/fixtures/bridge-frame.ts new file mode 100644 index 00000000..044ff66f --- /dev/null +++ b/tests/public-api/fixtures/bridge-frame.ts @@ -0,0 +1,109 @@ +import { bridgeRoutes } from '@/tests/public-api/fixtures/bridge-routes'; +import { + createMessageClient, + createIframeTransport, + createDataAppNavigation, +} from '@altertable/data-app/client'; +const bridge = createIframeTransport({ + timeoutMs: new URLSearchParams(window.location.search).has('timeout') + ? 1000 + : 10000, + parentOrigin: + document.documentElement.dataset.parentOrigin ?? window.location.origin, +}); +const navigation = createDataAppNavigation({ bridge }); +const messages = createMessageClient(bridgeRoutes, bridge.request); +const result = document.getElementById('result')!; +const location = document.getElementById('location')!; +const log = document.createElement('button'); +log.textContent = 'Write logs'; +log.addEventListener('click', () => { + bridge.logger.log('plain'); + bridge.logger.info(() => ['completed', { rows: 3 }]); + bridge.logger.warn('slow'); + bridge.logger.error('failed'); +}); +document.body.append(log); +const badLogs = document.createElement('button'); +badLogs.textContent = 'Write failing logs'; +badLogs.addEventListener('click', () => { + bridge.logger.log(() => { + throw new Error('Failed lazy argument'); + }); + bridge.logger.log(document.body); + result.textContent = 'App continued after logging'; +}); +document.body.append(badLogs); + +function showLocation() { + location.textContent = window.location.search + window.location.hash; +} + +window.addEventListener('popstate', showLocation); +document.getElementById('query')!.addEventListener('click', () => { + void messages.request('test:echo', { period: 'last-7' }).then(response => { + result.textContent = JSON.stringify(response); + }); +}); +document.getElementById('filter')!.addEventListener('click', () => { + window.history.pushState(null, '', '?period=last-7#daily'); + navigation.publish('push'); + showLocation(); +}); +showLocation(); + +let controller: AbortController; +for (const action of [ + 'Concurrent queries', + 'Wait for query', + 'Cancel query', + 'Flood queries', + 'Dispose transport', +]) { + const button = document.createElement('button'); + button.textContent = action; + button.addEventListener('click', () => { + const result = document.getElementById('result')!; + if (action === 'Dispose transport') { + bridge.dispose(); + return; + } + if (action === 'Cancel query') { + controller.abort(); + return; + } + if (action === 'Flood queries') { + controller = new AbortController(); + void Promise.all( + Array.from({ length: 200 }, () => + messages + .request('test:wait', 1, { signal: controller.signal }) + .catch(error => + error.name === 'AbortError' ? error.name : error.code + ) + ) + ).then(values => { + result.textContent = JSON.stringify(values); + }); + return; + } + if (action === 'Concurrent queries') { + void Promise.all( + Array.from({ length: 50 }, (_, index) => + messages.request('test:echo', { period: String(index) }) + ) + ).then(values => { + result.textContent = JSON.stringify(values); + }); + return; + } + controller = new AbortController(); + void messages + .request('test:wait', 1, { signal: controller.signal }) + .catch(error => { + result.textContent = + error.name === 'AbortError' ? error.name : (error.code ?? error.name); + }); + }); + document.body.append(button); +} diff --git a/tests/public-api/fixtures/bridge-host.html b/tests/public-api/fixtures/bridge-host.html new file mode 100644 index 00000000..d1914a8e --- /dev/null +++ b/tests/public-api/fixtures/bridge-host.html @@ -0,0 +1,11 @@ + + + + + Bridge host + + +
+ + + diff --git a/tests/public-api/fixtures/bridge-host.tsx b/tests/public-api/fixtures/bridge-host.tsx new file mode 100644 index 00000000..831ad055 --- /dev/null +++ b/tests/public-api/fixtures/bridge-host.tsx @@ -0,0 +1,112 @@ +import { + StrictMode, + useRef, + useReducer, + useState, + type ComponentRef, +} from 'react'; +import { createRoot } from 'react-dom/client'; +import { createMessageRouter } from '@altertable/data-app/contract'; +import { createHttpTransport } from '@altertable/data-app/client'; +import { + createNavigationHandler, + type DataAppLogger, +} from '@altertable/data-app/embed'; +import { bridgeRoutes } from '@/tests/public-api/fixtures/bridge-routes'; +import { DataAppBridge } from '@altertable/data-app/react/embed'; + +function Host() { + const [echoRequests, setEchoRequests] = useState(0); + const [pendingRequests, setPendingRequests] = useState(0); + const [cancelledRequests, setCancelledRequests] = useState(0); + + const [iframe, setIframe] = useState | null>(null); + const [generation, bumpGeneration] = useReducer(value => value + 1, 0); + const [version, bumpVersion] = useReducer(value => value + 1, 1); + const brokenLogger = useRef(false); + const [logging, setLogging] = useState(true); + const [loggerCalls, setLoggerCalls] = useState(0); + const [logs, setLogs] = useState([]); + function record(level: string, args: unknown[]) { + setLoggerCalls(previous => previous + 1); + if (brokenLogger.current) throw new Error('Consumer logger failed'); + if (!['plain', 'completed', 'slow', 'failed'].includes(String(args[0]))) + return; + setLogs(previous => [...previous, [version, level, ...args]]); + } + const logger: DataAppLogger = { + log: (...args) => record('log', args), + info: (...args) => record('info', args), + warn: (...args) => record('warn', args), + error: (...args) => record('error', args), + }; + const forward = createHttpTransport(); + const router = createMessageRouter(bridgeRoutes, { + 'data:query'({ operation, input }, { signal }) { + return forward(operation, input, signal); + }, + 'navigation:update': createNavigationHandler(), + 'test:wait'(_input, { signal }) { + setPendingRequests(value => value + 1); + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + setPendingRequests(value => value - 1); + setCancelledRequests(value => value + 1); + reject(signal.reason); + }, + { once: true } + ); + }); + }, + 'test:echo'({ period }) { + setEchoRequests(value => value + 1); + return { period, version }; + }, + }); + + return ( + <> + {echoRequests} + {pendingRequests} + {cancelledRequests} + + + + + {loggerCalls} + + {JSON.stringify(logs)} + + + + + + + diff --git a/tests/public-api/fixtures/styles.tsx b/tests/public-api/fixtures/styles.tsx new file mode 100644 index 00000000..b7cffc92 --- /dev/null +++ b/tests/public-api/fixtures/styles.tsx @@ -0,0 +1,25 @@ +import { createRoot } from 'react-dom/client'; +import { Grid, injectDataAppStyles } from '@altertable/data-app/react'; +function Report() { + return ( + +

First finding

+

Second finding

+
+ ); +} +createRoot(document.getElementById('root')!).render(); +let installed: HTMLStyleElement | undefined; +document.getElementById('inject')!.addEventListener('click', () => { + const style = injectDataAppStyles({ nonce: 'report-styles' }); + document.getElementById('identity')!.textContent = + installed === style ? 'Reused stylesheet' : 'Installed stylesheet'; + installed = style; +}); +document.getElementById('iframe')!.addEventListener('click', () => { + const target = document.querySelector('iframe')!.contentDocument!; + const root = target.createElement('div'); + target.body.append(root); + createRoot(root).render(); + injectDataAppStyles({ document: target, nonce: 'report-styles' }); +}); diff --git a/tests/public-api/fixtures/time-app.html b/tests/public-api/fixtures/time-app.html new file mode 100644 index 00000000..c8a0e78e --- /dev/null +++ b/tests/public-api/fixtures/time-app.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/public-api/fixtures/time-app.tsx b/tests/public-api/fixtures/time-app.tsx new file mode 100644 index 00000000..397a7a05 --- /dev/null +++ b/tests/public-api/fixtures/time-app.tsx @@ -0,0 +1,130 @@ +import { createDataClient } from '@altertable/data-app/client'; +import { + defineDateRangeContract, + defineOperation, + dimensionFilter, + parseDimensionSelection, + parseCount, + type DateRangeRequest, + type DimensionSelection, +} from '@altertable/data-app/contract'; +import { + createDataHooks, + createDataContext, + DataApp, + DataSection, + MetricWidget, + DataValue, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app/react'; + +const calendar = defineDateRangeContract({ + minDate: '2026-02-01', + maxDate: '2026-03-31', + maxRangeDays: 31, + timeZone: 'UTC', +}); +const region = dimensionFilter({ + key: 'region', + label: 'Region', + valueType: 'string', + selection: 'single', + allowMissing: true, + options: [ + { value: 'Europe', label: 'Europe' }, + { value: 'null', label: 'Literal null' }, + ], +}); +type Input = { period: DateRangeRequest; region: DimensionSelection }; +const operation = defineOperation({ + input(value: unknown): Input { + const input = value as Input; + return { + period: calendar.parseRequest(input.period), + region: parseDimensionSelection(input.region, region), + }; + }, + output: parseCount, + checks: [ + { + period: calendar.request({ start: '2026-03-04', end: '2026-03-06' }), + region: { kind: 'all' as const }, + }, + ], + policy: { maxQueryRows: 1, maxDurationMs: 1000, exposeSql: false }, + async run(_context, input) { + return input.region.kind === 'all' ? 12 : 0; + }, +}); +const client = createDataClient({ + operations: { activity: operation }, + lakehouse: { + async queryAll() { + return { columns: [], rows: [] }; + }, + }, +}); +const context = createDataContext({ activity: 'activity' })({ + description: 'Activity for the displayed period', + glossary: { + count: { + term: 'Events', + definition: 'Measured events', + queryNames: ['activity'], + }, + }, +}); +const view = createDataHooks(client).defineTimeView({ + dataContext: context, + operation: 'activity', + time: { + contract: calendar, + defaultValue: { kind: 'dates', start: '2026-03-04', end: '2026-03-06' }, + comparison: true, + }, + variables: { region }, + isEmpty: () => false, + emptyFallback: { title: 'No events' }, +}); +const metric = view.metric( + { id: 'count', glossaryId: 'count', format: { kind: 'count' } }, + current => ({ current, previous: 0 }) +); +const dataset = view.dataset({ + name: 'Events', + select: (count, input) => [ + { count, period: calendar.describeInput(input.period.range) }, + ], + rowKey: () => 'events', + columns: { + count: { value: row => row.count }, + period: { value: row => row.period }, + }, + evidence: { id: 'events', glossaryIds: ['count'] }, +}); +const content = view.content(source => ( + <> +

+ Displayed period: +

+ + Measured events: + + + +)); +const config = { + title: 'Calendar activity', + scope: { organization: 'test', environment: 'test' }, + appearance: {}, +}; +function App() { + return ( + []}> + + + ); +} +injectDataAppStyles(); +mountDataApp({ config, component: App }); diff --git a/tests/messages.test.ts b/tests/public-api/messages.test.ts similarity index 77% rename from tests/messages.test.ts rename to tests/public-api/messages.test.ts index 2d1db31a..c341c797 100644 --- a/tests/messages.test.ts +++ b/tests/public-api/messages.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test'; +import { expect, test } from 'vitest'; import { createMessageRouter, defineMessageRoute, @@ -76,11 +76,10 @@ test('typed routes validate both boundaries and reject unknown routes before inv }); }); -test('operation route validates the selected input and output, preserving evidence and public errors', async () => { - const contracts = { count: { input: number, output: number } }; - const route = defineDataQueryRoute(contracts); - expect(() => route.input({ operation: 'missing', input: 1 })).toThrow(); - expect(() => route.input({ operation: 'count', input: '1' })).toThrow(); +test('an operation message client validates delivered data and preserves public errors', async () => { + const route = defineDataQueryRoute({ + count: { input: number, output: number }, + }); const envelope = { data: 3, requestId: 'request', @@ -88,64 +87,43 @@ test('operation route validates the selected input and output, preserving eviden queryIds: ['q1'], queries: [{ name: 'totals', statement: 'SELECT 3', queryId: 'q1' }], }; - const router = createMessageRouter( + let response: unknown = { status: 200, body: envelope }; + const client = createMessageClient( { 'data:query': route }, - { - 'data:query'() { - return { status: 200, body: envelope }; - }, - } + async () => response ); - const client = createMessageClient({ 'data:query': route }, message => - router.dispatch(message, context()) - ); - expect( - await client.request('data:query', { operation: 'count', input: 1 }) - ).toEqual({ - status: 200, - body: envelope, - }); - expect(() => - route.output( - { status: 200, body: { ...envelope, data: 'wrong' } }, - { operation: 'count', input: 1 } - ) - ).toThrow(); - expect(() => - route.output( - { - status: 403, - body: { - error: { - code: 'forbidden', - message: 'Denied', - requestId: 'denied-request', - }, - }, - }, - { operation: 'count', input: 1 } - ) - ).toThrow(MessageRoutingError); - try { - route.output( - { - status: 403, - body: { - error: { - code: 'forbidden', - message: 'Denied', - requestId: 'denied-request', - }, - }, + await expect( + client.request('data:query', { operation: 'count', input: 1 }) + ).resolves.toEqual(response); + await expect( + // @ts-expect-error JavaScript callers can still send an unknown operation. + client.request('data:query', { operation: 'missing', input: 1 }) + ).rejects.toMatchObject({ code: 'invalid_payload' }); + await expect( + // @ts-expect-error JavaScript callers can still supply an invalid input. + client.request('data:query', { operation: 'count', input: '1' }) + ).rejects.toMatchObject({ code: 'invalid_payload' }); + response = { status: 200, body: { ...envelope, data: 'wrong' } }; + await expect( + client.request('data:query', { operation: 'count', input: 1 }) + ).rejects.toMatchObject({ code: 'invalid_response' }); + response = { + status: 403, + body: { + error: { + code: 'forbidden', + message: 'Denied', + requestId: 'denied-request', }, - { operation: 'count', input: 1 } - ); - } catch (error) { - expect(error).toMatchObject({ - code: 'forbidden', - requestId: 'denied-request', - }); - } + }, + }; + await expect( + client.request('data:query', { operation: 'count', input: 1 }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: 'Denied', + requestId: 'denied-request', + }); }); test('navigation route validates mode and location and preserves reserved host parameters', async () => { @@ -230,30 +208,42 @@ test('routing checks cancellation before and after handlers', async () => { ).toMatchObject({ name: 'AbortError' }); }); -test('SQL route validates statements, safe row bounds, and result shape', async () => { - for (const value of [ +test('a SQL message client rejects malformed requests and malformed upstream results', async () => { + const input = { statement: 'SELECT 1', limit: 1 }; + const result = { columns: [{ name: 'n' }], rows: [[1]], queryId: 'q' }; + let response: unknown = result; + const router = createMessageRouter( + { 'data:sql': sqlQueryRoute }, + { 'data:sql': () => response as typeof result } + ); + const client = createMessageClient({ 'data:sql': sqlQueryRoute }, message => + router.dispatch(message, context()) + ); + await expect(client.request('data:sql', input)).resolves.toEqual(result); + for (const payload of [ null, {}, { statement: '', limit: 1 }, { statement: 'SELECT 1', limit: 0 }, { statement: 'SELECT 1', limit: 1.5 }, { statement: 'SELECT 1', limit: Number.MAX_SAFE_INTEGER + 1 }, - ]) - expect(() => sqlQueryRoute.input(value)).toThrow(); - const input = { statement: 'SELECT 1', limit: 1 }; - expect(sqlQueryRoute.input({ ...input, credentials: 'discard' })).toEqual( - input - ); - const result = { columns: [{ name: 'n' }], rows: [[1]], queryId: 'q' }; - expect(sqlQueryRoute.output(result, input)).toEqual(result); - for (const value of [ + ]) { + await expect( + router.dispatch({ route: 'data:sql', payload }, context()) + ).rejects.toMatchObject({ code: 'invalid_payload' }); + } + for (const body of [ null, { ...result, rows: [[1], [2]] }, { ...result, rows: [['wrong', 'width']] }, { ...result, columns: [{}] }, { ...result, queryId: 1 }, - ]) - expect(() => sqlQueryRoute.output(value, input)).toThrow(); + ]) { + response = body; + await expect(client.request('data:sql', input)).rejects.toMatchObject({ + code: 'invalid_response', + }); + } }); test('SQL host authorizes each request, preserves source errors, and hides private failures', async () => { diff --git a/tests/messages.types.ts b/tests/public-api/messages.types.ts similarity index 100% rename from tests/messages.types.ts rename to tests/public-api/messages.types.ts diff --git a/tests/operations.test.ts b/tests/public-api/operations.test.ts similarity index 65% rename from tests/operations.test.ts rename to tests/public-api/operations.test.ts index 80d26d4e..66cf3f87 100644 --- a/tests/operations.test.ts +++ b/tests/public-api/operations.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test'; +import { expect, test } from 'vitest'; import { createDataClient, DataAppError } from '@altertable/data-app/client'; import { defineOperation, @@ -202,33 +202,108 @@ test('browser operation clients require a SQL adapter', async () => { ).toBeInstanceOf(DataAppError); }); -test('HTTP returns the exact serialization checked against the response byte limit', async () => { - let serializations = 0; - const count = { - ...operation(), - output() { - return { - toJSON() { - serializations++; - return serializations === 1 - ? 'safe' - : 'unexpected second serialization'; +test('an authorized client queries an operation with validated input and query evidence', async () => { + const operations = { + count: defineOperation({ + input: parseCount, + output: parseCount, + checks: [3], + queryNames: { count: 'count' }, + policy: { maxQueryRows: 2, maxDurationMs: 1000, exposeSql: true }, + async run({ query }, input) { + const result = await query('count', `SELECT ${input}`); + return result.rows[0]![0]; + }, + }), + }; + let authorized = true; + const handler = createDataHandler(operations, async () => { + if (!authorized) throw new Error('Access denied'); + return { + canDiscloseSql: true, + lakehouse: { + async queryAll() { + return { + columns: [{ name: 'count' }], + rows: [[3]], + queryId: 'query-1', + }; }, - }; - }, + }, + }; + }); + const client = createDataClient({ + endpoint: 'https://app.example/api/data', + fetch: Object.assign( + async (url: RequestInfo | URL, options?: RequestInit) => + handler(new Request(url, options)), + { preconnect() {} } + ), + }); + + await expect(client.query('count', 3)).resolves.toMatchObject({ + data: 3, + input: 3, + queryIds: ['query-1'], + queries: [{ name: 'count', statement: 'SELECT 3', queryId: 'query-1' }], + }); + await expect(client.query('count', -1)).rejects.toMatchObject({ + code: 'invalid_input', + }); + + authorized = false; + await expect(client.query('count', 3)).rejects.toMatchObject({ + code: 'forbidden', + }); +}); + +test('an HTTP client rejects malformed results and exposes actionable errors without upstream details', async () => { + const valid = { + data: 3, + requestId: 'request', + queriedAt: 'now', + queryIds: [], }; - const handler = createDataHandler({ count }, async () => ({ - lakehouse: lakehouse(), - canDiscloseSql: false, - })); - const response = await handler( - new Request('http://localhost/api/data/count', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '3', - }) - ); - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ data: 'safe' }); - expect(serializations).toBe(1); + let body: unknown = valid; + let status = 200; + const client = createDataClient({ + endpoint: 'https://app.example/api/data', + fetch: Object.assign( + async () => + typeof body === 'string' + ? new Response(body, { status }) + : Response.json(body, { status }), + { preconnect() {} } + ), + }); + await expect(client.query('count', {})).resolves.toMatchObject({ data: 3 }); + for (const malformed of [ + {}, + { requestId: 'request', queriedAt: 'now', queryIds: [] }, + { ...valid, requestId: undefined }, + { ...valid, queriedAt: undefined }, + { ...valid, queryIds: [1] }, + { ...valid, queries: [{ name: 'query', statement: 1 }] }, + [], + ]) { + body = malformed; + await expect(client.query('count', {})).rejects.toMatchObject({ + code: 'invalid_response', + }); + } + status = 403; + body = { + error: { code: 'forbidden', message: 'Denied', requestId: 'denied' }, + }; + await expect(client.query('count', {})).rejects.toMatchObject({ + code: 'forbidden', + message: 'Denied', + requestId: 'denied', + }); + status = 502; + body = 'Private upstream details'; + await expect(client.query('count', {})).rejects.toMatchObject({ + code: 'request_failed', + message: 'Could not load data.', + }); }); diff --git a/tests/server-bun.test.ts b/tests/public-api/server-bun.test.ts similarity index 98% rename from tests/server-bun.test.ts rename to tests/public-api/server-bun.test.ts index 074b4488..5ace7f7a 100644 --- a/tests/server-bun.test.ts +++ b/tests/public-api/server-bun.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test'; +import { expect, test } from 'vitest'; import { localLakehouse } from '@altertable/data-app/server/bun'; test('local lakehouse delivers CLI proxy queries with authentication and query evidence', async () => { diff --git a/tests/server.test.ts b/tests/public-api/server.test.ts similarity index 84% rename from tests/server.test.ts rename to tests/public-api/server.test.ts index 8246c084..bb658613 100644 --- a/tests/server.test.ts +++ b/tests/public-api/server.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test'; +import { expect, test } from 'vitest'; import { defineOperation, defineQueryNames, @@ -327,69 +327,6 @@ test('execution deadlines settle operations that ignore cancellation', async () ).toBe(504); }); -test('operation success and synchronous failure remove cancellation listeners', async () => { - const originalAdd = Reflect.get( - AbortSignal.prototype, - 'addEventListener' - ) as AbortSignal['addEventListener']; - const originalRemove = Reflect.get( - AbortSignal.prototype, - 'removeEventListener' - ) as AbortSignal['removeEventListener']; - const active = new Map< - AbortSignal, - Set - >(); - AbortSignal.prototype.addEventListener = function addListener( - this: AbortSignal, - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions - ) { - if (type === 'abort' && listener) { - const listeners = active.get(this) ?? new Set(); - listeners.add(listener); - active.set(this, listeners); - } - - return originalAdd.call(this, type, listener, options); - }; - AbortSignal.prototype.removeEventListener = function removeListener( - this: AbortSignal, - type: string, - listener: EventListenerOrEventListenerObject, - options?: boolean | EventListenerOptions - ) { - if (type === 'abort' && listener) active.get(this)?.delete(listener); - - return originalRemove.call(this, type, listener, options); - }; - try { - for (const fail of [false, true]) { - let executionSignal: AbortSignal | undefined; - const handler = createDataHandler( - { - cancel: cancellationOperation(signal => { - executionSignal = signal; - if (fail) throw new Error('Synchronous failure'); - - return Promise.resolve(1); - }), - }, - async () => cancellationAccess - ); - expect( - (await handler(cancellableRequest(new AbortController()))).status - ).toBe(fail ? 502 : 200); - expect(executionSignal).toBeDefined(); - expect(active.get(executionSignal!)?.size).toBe(0); - } - } finally { - AbortSignal.prototype.addEventListener = originalAdd; - AbortSignal.prototype.removeEventListener = originalRemove; - } -}); - function streamedRequest( chunks: Uint8Array[], options: { @@ -420,15 +357,20 @@ function streamedRequest( }, { highWaterMark: 0 } ); - const request = new Request('http://localhost/api/data/cancel', { + const requestOptions = { method: 'POST', body, + duplex: 'half', signal: options.signal, headers: { 'content-type': 'application/json', ...(options.length && { 'content-length': options.length }), }, - }); + }; + const request = new Request( + 'http://localhost/api/data/cancel', + requestOptions + ); return { request, state }; } @@ -478,9 +420,7 @@ test('oversize input cancels remaining consumption regardless of Content-Length' ); const response = await responseWithin(inputHandler(() => calls++)(request)); expect(response.status).toBe(413); - expect(state.pulls).toBe(1); expect(state.cancelled).toBe(true); - expect(request.body?.locked).toBe(false); expect(calls).toBe(0); } }); @@ -525,7 +465,6 @@ test('cancellation settles waiting body reads without awaiting stream cancellati setTimeout(() => controller.abort(), 0); const response = await responseWithin(pending); expect(response.status).toBe(400); - expect(request.body?.locked).toBe(false); expect(state.cancelled).toBe(true); expect((await response.json()).error.code).toBe('invalid_input'); }); diff --git a/tests/style-contract.types.ts b/tests/public-api/style-contract.types.ts similarity index 100% rename from tests/style-contract.types.ts rename to tests/public-api/style-contract.types.ts diff --git a/tests/widgets.types.ts b/tests/public-api/widgets.types.ts similarity index 100% rename from tests/widgets.types.ts rename to tests/public-api/widgets.types.ts diff --git a/tests/worker.test.ts b/tests/public-api/worker.test.ts similarity index 76% rename from tests/worker.test.ts rename to tests/public-api/worker.test.ts index c34ac3b5..b49c0366 100644 --- a/tests/worker.test.ts +++ b/tests/public-api/worker.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, test } from 'bun:test'; -import { runInNewContext } from 'node:vm'; +import { describe, expect, test } from 'vitest'; // Execute the built deployment artifact, without rebundling package source. -const { default: worker } = (await import( +const { default: worker } = await import( import.meta.resolve('@altertable/data-app/worker') -)) as typeof import('@/src/worker'); +); const bindings = { DOMAIN_NAME: 'altertableusercontent.dev', PARENT_ORIGINS: 'https://altertable.dev https://*.preview.altertable.dev', @@ -19,33 +18,6 @@ function request(parent?: string, method = 'GET', config = bindings) { } describe('published Worker', () => { - test('escapes script endings without changing JavaScript string values', async () => { - const value = ''; - const result = await Bun.build({ - entrypoints: ['src/worker/index.ts'], - target: 'browser', - format: 'esm', - define: { - DATA_APP_BOOTSTRAP: JSON.stringify( - `globalThis.value = ${JSON.stringify(value)};` - ), - }, - }); - expect(result.success).toBe(true); - const source = await result.outputs[0]!.text(); - const { default: fixture } = (await import( - `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` - )) as typeof import('@/src/worker'); - const html = await fixture.fetch(new Request(previewUrl), bindings).text(); - expect(html.match(/<\/script/gi)).toHaveLength(1); - const script = html.match( - /]*>([\s\S]*?)<\/script\b[^>]*>/i - )![1]!; - const context: { value?: string } = {}; - runInNewContext(script, context); - expect(context.value).toBe(value); - }); - test('serves unstyled classic bootstrap with restrictive headers and empty HEAD', async () => { const get = request(); const head = request(undefined, 'HEAD'); @@ -59,12 +31,6 @@ describe('published Worker', () => { expect(get.headers.get('content-security-policy')).toBe( "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors https://altertable.dev https://*.preview.altertable.dev" ); - const html = await get.text(); - expect(html).toMatch( - /
<\/div>\s*