From bf908a8474b950be1ec8086c69c855d06d659597 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:39:26 +0300 Subject: [PATCH 1/3] =?UTF-8?q?test(embed):=20move=20integration=20tests?= =?UTF-8?q?=20onto=20@playwright/test=20under=20tests/e2e=20=E2=80=94=20th?= =?UTF-8?q?e=20runner=20owns=20page=20lifecycle=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failed assertions in the old hand-rolled `chromium.launch()`/`browser.newPage()` vitest suite leaked live Chromium pages: nothing guaranteed a close on a failure path, and those leaks pressured CI shard-4 and stranded sibling vitest browser sessions. Kill the leak class by construction: @playwright/test owns the browser/context/page lifecycle end to end via its built-in fixtures, so a failed assertion can no longer skip a close. Splits packages/embed's test suite by kind: - tests/e2e/ — the 22 real browser-driven integration tests (*.it.test.ts), now run by `playwright test`. Kit boots stay per-file via test.beforeAll/test.afterAll; per-test pages come from the page/context fixtures; multi-page tests use context.newPage(); the two reload-simulation tests in embed.it.test.ts keep their intentional mid-body page.close(). - tests/unit/ — the 4 build/bundle assertion tests plus proxy-upgrade (an integration test against a real HTTP/WS server that never opens a browser page), still run by vitest. - tests/helpers/ — boot.ts, host.ts, proxy.ts: shared, non-playwright helpers used by both tiers. - tests/e2e/helpers/ — the playwright-only helpers (suite.ts and probe-suite.ts no longer own a browser at all). - tests/fixtures/, tests/dist/, tests/globals.d.ts moved alongside. Supersedes the closed #411 try/finally hand-wrapping attempt and epic #409. Rebased onto #410 (port-drift navigation.set deadline fix) after it merged. Also fixes a latent gap in the banned-vocabulary allowlist: the renamed native-bridge.ts helper was never covered under its old path either, so add the new path to NATIVE_AND_REACT_BRIDGE_PATHS. Co-Authored-By: Claude Fable 5 --- .oxlintrc.json | 2 +- packages/embed/package.json | 4 +- packages/embed/playwright.config.ts | 21 ++ packages/embed/test/panel-focus.it.test.ts | 100 -------- .../e2e}/composer-rich-input.it.test.ts | 135 ++++++----- .../e2e}/composer-trigger-menu.it.test.ts | 78 +++---- .../e2e}/connection-pool.it.test.ts | 35 ++- .../e2e}/create-conciv.it.test.ts | 90 ++++--- .../e2e}/draft-selection.it.test.ts | 8 +- .../e2e}/element-capture.it.test.ts | 66 +++--- .../{test => tests/e2e}/embed.it.test.ts | 221 ++++++++---------- .../e2e}/forced-drop.it.test.ts | 33 ++- .../embed/{test => tests/e2e}/helpers/chat.ts | 7 +- .../{test => tests/e2e}/helpers/handle.ts | 2 +- .../e2e}/helpers/native-bridge.ts | 2 +- .../{test => tests/e2e}/helpers/navigation.ts | 4 +- .../e2e}/helpers/page-plane-host.ts | 8 +- .../{test => tests/e2e}/helpers/panel.ts | 9 +- .../e2e}/helpers/probe-server.ts | 0 .../e2e}/helpers/probe-suite.ts | 18 +- .../{test => tests/e2e}/helpers/suite.ts | 19 +- .../e2e}/model-selector.it.test.ts | 17 +- .../e2e}/native-live-region.it.test.ts | 48 ++-- .../e2e}/native-widget.it.test.ts | 71 +++--- .../e2e}/navigation-hold.it.test.ts | 36 ++- .../e2e}/page-dispatch-boot.it.test.ts | 51 ++-- .../e2e}/page-dispatch-parity.it.test.ts | 50 ++-- .../{test => tests/e2e}/page-plane.it.test.ts | 38 ++- .../embed/tests/e2e/panel-focus.it.test.ts | 90 +++++++ .../{test => tests/e2e}/rebind.it.test.ts | 109 +++++---- .../e2e}/recording-attachment.it.test.ts | 34 ++- .../e2e}/reload-continuity.it.test.ts | 23 +- .../{test => tests/e2e}/rpc-fault.it.test.ts | 8 +- .../e2e}/rpc-observer.it.test.ts | 55 ++--- .../e2e}/transport-selection.it.test.ts | 45 ++-- .../{test => tests}/fixtures/global-entry.ts | 0 .../{test => tests}/fixtures/handle-entry.tsx | 0 .../{test => tests}/fixtures/ws-probe.ts | 0 packages/embed/{test => tests}/globals.d.ts | 0 .../embed/{test => tests}/helpers/boot.ts | 0 .../embed/{test => tests}/helpers/host.ts | 0 .../embed/{test => tests}/helpers/proxy.ts | 0 .../unit}/mount-externals.test.ts | 2 +- .../{test => tests/unit}/mount-node.test.ts | 2 +- .../unit}/native-bundle.test.ts | 2 +- .../unit}/navigation-storage.test.ts | 2 +- .../unit/proxy-upgrade.test.ts} | 4 +- packages/embed/tsconfig.json | 7 +- packages/embed/vite.global.config.ts | 2 +- packages/embed/vite.handle.config.ts | 4 +- packages/embed/vite.ws-probe.config.ts | 4 +- packages/embed/vitest.config.ts | 2 +- .../oxlint-plugin/src/banned-vocabulary.js | 1 + pnpm-lock.yaml | 6 +- 54 files changed, 720 insertions(+), 855 deletions(-) create mode 100644 packages/embed/playwright.config.ts delete mode 100644 packages/embed/test/panel-focus.it.test.ts rename packages/embed/{test => tests/e2e}/composer-rich-input.it.test.ts (58%) rename packages/embed/{test => tests/e2e}/composer-trigger-menu.it.test.ts (56%) rename packages/embed/{test => tests/e2e}/connection-pool.it.test.ts (63%) rename packages/embed/{test => tests/e2e}/create-conciv.it.test.ts (59%) rename packages/embed/{test => tests/e2e}/draft-selection.it.test.ts (81%) rename packages/embed/{test => tests/e2e}/element-capture.it.test.ts (71%) rename packages/embed/{test => tests/e2e}/embed.it.test.ts (57%) rename packages/embed/{test => tests/e2e}/forced-drop.it.test.ts (66%) rename packages/embed/{test => tests/e2e}/helpers/chat.ts (61%) rename packages/embed/{test => tests/e2e}/helpers/handle.ts (94%) rename packages/embed/{test => tests/e2e}/helpers/native-bridge.ts (95%) rename packages/embed/{test => tests/e2e}/helpers/navigation.ts (97%) rename packages/embed/{test => tests/e2e}/helpers/page-plane-host.ts (63%) rename packages/embed/{test => tests/e2e}/helpers/panel.ts (62%) rename packages/embed/{test => tests/e2e}/helpers/probe-server.ts (100%) rename packages/embed/{test => tests/e2e}/helpers/probe-suite.ts (55%) rename packages/embed/{test => tests/e2e}/helpers/suite.ts (52%) rename packages/embed/{test => tests/e2e}/model-selector.it.test.ts (53%) rename packages/embed/{test => tests/e2e}/native-live-region.it.test.ts (78%) rename packages/embed/{test => tests/e2e}/native-widget.it.test.ts (73%) rename packages/embed/{test => tests/e2e}/navigation-hold.it.test.ts (77%) rename packages/embed/{test => tests/e2e}/page-dispatch-boot.it.test.ts (66%) rename packages/embed/{test => tests/e2e}/page-dispatch-parity.it.test.ts (75%) rename packages/embed/{test => tests/e2e}/page-plane.it.test.ts (55%) create mode 100644 packages/embed/tests/e2e/panel-focus.it.test.ts rename packages/embed/{test => tests/e2e}/rebind.it.test.ts (60%) rename packages/embed/{test => tests/e2e}/recording-attachment.it.test.ts (77%) rename packages/embed/{test => tests/e2e}/reload-continuity.it.test.ts (60%) rename packages/embed/{test => tests/e2e}/rpc-fault.it.test.ts (79%) rename packages/embed/{test => tests/e2e}/rpc-observer.it.test.ts (79%) rename packages/embed/{test => tests/e2e}/transport-selection.it.test.ts (65%) rename packages/embed/{test => tests}/fixtures/global-entry.ts (100%) rename packages/embed/{test => tests}/fixtures/handle-entry.tsx (100%) rename packages/embed/{test => tests}/fixtures/ws-probe.ts (100%) rename packages/embed/{test => tests}/globals.d.ts (100%) rename packages/embed/{test => tests}/helpers/boot.ts (100%) rename packages/embed/{test => tests}/helpers/host.ts (100%) rename packages/embed/{test => tests}/helpers/proxy.ts (100%) rename packages/embed/{test => tests/unit}/mount-externals.test.ts (96%) rename packages/embed/{test => tests/unit}/mount-node.test.ts (84%) rename packages/embed/{test => tests/unit}/native-bundle.test.ts (95%) rename packages/embed/{test => tests/unit}/navigation-storage.test.ts (96%) rename packages/embed/{test/proxy-upgrade.it.test.ts => tests/unit/proxy-upgrade.test.ts} (94%) diff --git a/.oxlintrc.json b/.oxlintrc.json index 1a2bdc15b..db411ea7a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -178,7 +178,7 @@ } }, { - "files": ["packages/embed/test/**", "packages/extension-testkit/src/**"], + "files": ["packages/embed/tests/**", "packages/extension-testkit/src/**"], "rules": { "playwright/prefer-web-first-assertions": "error" } diff --git a/packages/embed/package.json b/packages/embed/package.json index 17fa3c002..e54b7b21e 100644 --- a/packages/embed/package.json +++ b/packages/embed/package.json @@ -35,7 +35,7 @@ "build": "vite build && vite build --config vite.global.config.ts && vite build --config vite.native.config.ts && tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "oxlint", - "test": "vite build --config vite.handle.config.ts && vite build --config vite.ws-probe.config.ts && vitest run", + "test": "vite build --config vite.handle.config.ts && vite build --config vite.ws-probe.config.ts && vitest run && playwright test", "publint": "publint", "attw": "attw --pack . --profile esm-only" }, @@ -74,12 +74,12 @@ "@orpc/client": "catalog:", "@orpc/server": "catalog:", "@orpc/standard-server-peer": "catalog:", + "@playwright/test": "^1.61.1", "@solid-primitives/event-listener": "^2.4.6", "@types/node": "^26.1.0", "@unocss/postcss": "^66.7.2", "@unocss/reset": "^66.7.2", "@vitest/coverage-v8": "^4.1.8", - "playwright": "^1.61.1", "publint": "^0.3.14", "tsdown": "^0.22.4", "typescript": "^6.0.3", diff --git a/packages/embed/playwright.config.ts b/packages/embed/playwright.config.ts new file mode 100644 index 000000000..1591fb9a4 --- /dev/null +++ b/packages/embed/playwright.config.ts @@ -0,0 +1,21 @@ +import {defineConfig, devices, type ReporterDescription} from '@playwright/test' + +function reporters(): ReporterDescription[] { + if (!process.env.GITHUB_ACTIONS) return [['line']] + return [['line'], ['json', {outputFile: 'test-results.json'}]] +} + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.it.test.ts', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: {timeout: 30_000}, + reporter: reporters(), + retries: 0, + use: { + trace: 'retain-on-failure', + }, + projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}], +}) diff --git a/packages/embed/test/panel-focus.it.test.ts b/packages/embed/test/panel-focus.it.test.ts deleted file mode 100644 index c0d33514b..000000000 --- a/packages/embed/test/panel-focus.it.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import {describe, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import type {Locator, Page} from 'playwright' -import {setupWidgetSuite} from './helpers/suite.js' -import {openPanel} from './helpers/panel.js' -import {hostPage, serveHost} from './helpers/host.js' - -const suite = setupWidgetSuite() - -const COMPOSER_NAME = 'Message the conciv agent' - -function composer(page: Page) { - return page.getByRole('textbox', {name: COMPOSER_NAME}) -} - -async function ensurePanelClosed(page: Page): Promise { - const minimize = page.getByRole('button', {name: 'Minimize conciv chat'}) - const opener = page.getByRole('button', {name: 'Open conciv chat'}) - await expectLocator(minimize.or(opener)).toBeVisible({timeout: 30_000}) - if (await minimize.isVisible()) await minimize.click() - await expectLocator(opener).toBeVisible({timeout: 30_000}) -} - -type HostedPanel = {host: Awaited>; page: Page; hostButton: Locator} - -async function openPanelOverFocusedHostButton(): Promise { - const host = await serveHost(() => - hostPage({ - apiBase: suite.kit().base, - widget: '{"quickTerminal":false}', - body: '', - }), - ) - const page = await suite.browser().newPage() - await page.goto(host.base, {waitUntil: 'domcontentloaded'}) - await ensurePanelClosed(page) - const hostButton = page.getByRole('button', {name: 'Host action'}) - await hostButton.click() - await expectLocator(hostButton).toBeFocused() - await page.evaluate(() => window.dispatchEvent(new Event('conciv:open-panel'))) - await expectLocator(composer(page)).toBeVisible({timeout: 30_000}) - return {host, page, hostButton} -} - -describe('panel open focuses the composer', () => { - it('focuses the composer input when the panel opens', async () => { - const page = await suite.browser().newPage() - await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) - await openPanel(page) - await expectLocator(composer(page)).toBeFocused({timeout: 10_000}) - await page.keyboard.type('typed without clicking') - await expectLocator(composer(page)).toHaveText('typed without clicking') - await page.close() - }) -}) - -describe('panel close restores focus: host element captured at open wins, FAB is the fallback', () => { - it('closing via the FAB restores the host element that was focused before a programmatic open', async () => { - const {host, page, hostButton} = await openPanelOverFocusedHostButton() - await page.getByRole('button', {name: 'Minimize conciv chat'}).click() - await expectLocator(hostButton).toBeFocused({timeout: 10_000}) - await page.close() - await host.close() - }) - - it('closing via the panel header restores the host element that was focused before the open', async () => { - const {host, page, hostButton} = await openPanelOverFocusedHostButton() - await page.getByRole('button', {name: 'Close chat'}).click() - await expectLocator(hostButton).toBeFocused({timeout: 10_000}) - await page.close() - await host.close() - }) - - it('collapsing the panel by dragging its resize handle shut restores the host element', async () => { - const {host, page, hostButton} = await openPanelOverFocusedHostButton() - - const handle = page.getByRole('separator', {name: 'Resize chat height'}) - const grip = await handle.boundingBox() - if (!grip) throw new Error('the resize handle is not laid out') - await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2) - await page.mouse.down() - await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 600, {steps: 12}) - await page.mouse.up() - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) - await expectLocator(hostButton).toBeFocused({timeout: 10_000}) - await page.close() - await host.close() - }) - - it('closing via the FAB falls back to FAB focus when no host element was captured at open time', async () => { - const page = await suite.browser().newPage() - await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) - await ensurePanelClosed(page) - await page.getByRole('button', {name: 'Open conciv chat'}).click() - await expectLocator(composer(page)).toBeVisible({timeout: 30_000}) - await page.getByRole('button', {name: 'Minimize conciv chat'}).click() - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeFocused({timeout: 10_000}) - await page.close() - }) -}) diff --git a/packages/embed/test/composer-rich-input.it.test.ts b/packages/embed/tests/e2e/composer-rich-input.it.test.ts similarity index 58% rename from packages/embed/test/composer-rich-input.it.test.ts rename to packages/embed/tests/e2e/composer-rich-input.it.test.ts index 722e5dbed..e569fc818 100644 --- a/packages/embed/test/composer-rich-input.it.test.ts +++ b/packages/embed/tests/e2e/composer-rich-input.it.test.ts @@ -1,6 +1,4 @@ -import {describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import type {Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {z} from 'zod' import recorderServer from '@conciv/extension-recorder' import {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' @@ -24,8 +22,7 @@ const panel = (page: Page) => page.getByRole('dialog', {name: 'conciv chat agent const sendInputSchema = z.object({content: z.string()}) -async function observedPage(): Promise<{page: Page; observer: RpcObserver}> { - const page = await suite.browser().newPage() +function observedPage(page: Page): {page: Page; observer: RpcObserver} { return {page, observer: observeRpc(page)} } @@ -48,24 +45,26 @@ async function openComposer(page: Page): Promise { await openPanel(page) const input = composer(page) await input.click() - await expectLocator(input).toHaveText('') + await expect(input).toHaveText('') } async function pickSuggestion(page: Page, listName: string, optionName: string): Promise { const listbox = page.getByRole('listbox', {name: listName}) const option = listbox.getByRole('option', {name: optionName}) - await expectLocator(option).toBeVisible({timeout: 10_000}) - await expectLocator(option).toHaveAttribute('id', /.+/, {timeout: 10_000}) + await expect(option).toBeVisible({timeout: 10_000}) + await expect(option).toHaveAttribute('id', /.+/, {timeout: 10_000}) const optionId = await option.getAttribute('id') if (!optionId) throw new Error(`option "${optionName}" in "${listName}" rendered without an id`) - await expectLocator(composer(page)).toHaveAttribute('aria-activedescendant', optionId, {timeout: 10_000}) + await expect(composer(page)).toHaveAttribute('aria-activedescendant', optionId, {timeout: 10_000}) await page.keyboard.press('Enter') - await expectLocator(listbox).toBeHidden({timeout: 10_000}) + await expect(listbox).toBeHidden({timeout: 10_000}) } -describe('the rich composer input in the live widget shadow DOM', () => { - it('types multiline text with slash and mention chips and submits the exact directive string', async () => { - const {page, observer} = await observedPage() +test.describe('the rich composer input in the live widget shadow DOM', () => { + test('types multiline text with slash and mention chips and submits the exact directive string', async ({ + page: fixturePage, + }) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -84,50 +83,52 @@ describe('the rich composer input in the live widget shadow DOM', () => { const sent = waitForSend(observer) await page.getByRole('button', {name: 'Send message'}).click() expect(await sent).toBe(expected) - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) - await expectLocator(input).toHaveText('') + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(input).toHaveText('') observer.dispose() - await page.close() }) - it('backspace removes a selected command chip in two steps, never a partial directive', async () => { - const {page, observer} = await observedPage() + test('backspace removes a selected command chip in two steps, never a partial directive', async ({ + page: fixturePage, + }) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) await input.pressSequentially('/compact') await pickSuggestion(page, 'Commands', '/compact') - await expectLocator(input).toHaveText('/compact ') + await expect(input).toHaveText('/compact ') await page.keyboard.press('Backspace') - await expectLocator(input).toHaveText('/compact') + await expect(input).toHaveText('/compact') await page.keyboard.press('Backspace') - await expectLocator(input).toHaveText('') + await expect(input).toHaveText('') observer.dispose() - await page.close() }) - it('forward delete removes the chip ahead of the caret in one step', async () => { - const {page, observer} = await observedPage() + test('forward delete removes the chip ahead of the caret in one step', async ({page: fixturePage}) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) await input.pressSequentially('/compact') await pickSuggestion(page, 'Commands', '/compact') - await expectLocator(input).toHaveText('/compact ') + await expect(input).toHaveText('/compact ') await page.keyboard.press('Home') await input.pressSequentially('x') - await expectLocator(input).toHaveText('x/compact ') + await expect(input).toHaveText('x/compact ') await page.keyboard.press('Delete') - await expectLocator(input).toHaveText('x ') + await expect(input).toHaveText('x ') observer.dispose() - await page.close() }) - it('Escape closes the typeahead first, cancels the run next, and closes the panel last', async () => { - const {page, observer} = await observedPage() + test('Escape closes the typeahead first, cancels the run next, and closes the panel last', async ({ + page: fixturePage, + }) => { + test.setTimeout(120_000) + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -136,34 +137,33 @@ describe('the rich composer input in the live widget shadow DOM', () => { await input.pressSequentially('keep this running') await page.getByRole('button', {name: 'Send message'}).click() const stop = page.getByRole('button', {name: 'Stop generating'}) - await expectLocator(stop).toBeVisible({timeout: 30_000}) + await expect(stop).toBeVisible({timeout: 30_000}) await input.click() await input.pressSequentially('/config') const listbox = page.getByRole('listbox', {name: 'Commands'}) - await expectLocator(listbox.getByRole('option', {name: '/config'})).toBeVisible({timeout: 10_000}) + await expect(listbox.getByRole('option', {name: '/config'})).toBeVisible({timeout: 10_000}) await page.keyboard.press('Escape') - await expectLocator(listbox).toBeHidden({timeout: 10_000}) - await expectLocator(input).toHaveText('/config') - await expectLocator(stop).toBeVisible() - await expectLocator(panel(page)).toBeVisible() + await expect(listbox).toBeHidden({timeout: 10_000}) + await expect(input).toHaveText('/config') + await expect(stop).toBeVisible() + await expect(panel(page)).toBeVisible() await page.keyboard.press('Escape') - await expectLocator(stop).toBeHidden({timeout: 30_000}) - await expectLocator(panel(page)).toBeVisible() + await expect(stop).toBeHidden({timeout: 30_000}) + await expect(panel(page)).toBeVisible() } finally { suite.kit().harness.script.release() } await page.keyboard.press('Escape') - await expectLocator(panel(page)).toBeHidden({timeout: 30_000}) + await expect(panel(page)).toBeHidden({timeout: 30_000}) observer.dispose() - await page.close() }) - it('a failed send restores the full draft into the composer', async () => { - const {page, observer} = await observedPage() + test('a failed send restores the full draft into the composer', async ({page: fixturePage}) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -171,14 +171,13 @@ describe('the rich composer input in the live widget shadow DOM', () => { await input.pressSequentially('bring me back') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByRole('alert', {name: 'the run collapsed'})).toBeVisible({timeout: 30_000}) - await expectLocator(input).toHaveText('bring me back') + await expect(page.getByRole('alert', {name: 'the run collapsed'})).toBeVisible({timeout: 30_000}) + await expect(input).toHaveText('bring me back') observer.dispose() - await page.close() }) - it('a reload restores the draft as plain directive text with the caret at the end', async () => { - const {page, observer} = await observedPage() + test('a reload restores the draft as plain directive text with the caret at the end', async ({page: fixturePage}) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -189,37 +188,37 @@ describe('the rich composer input in the live widget shadow DOM', () => { await page.reload({waitUntil: 'domcontentloaded'}) const restored = composer(page) - await expectLocator(restored).toHaveText('ship /config ', {timeout: 30_000}) + await expect(restored).toHaveText('ship /config ', {timeout: 30_000}) await page.keyboard.type('!') - await expectLocator(restored).toHaveText('ship /config !') + await expect(restored).toHaveText('ship /config !') observer.dispose() - await page.close() }) - it('a reload restores the caret where the draft left it, not at the end', async () => { - const {page, observer} = await observedPage() + test('a reload restores the caret where the draft left it, not at the end', async ({page: fixturePage}) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) await input.pressSequentially('hello world') for (let step = 0; step < 6; step += 1) await page.keyboard.press('ArrowLeft') await input.pressSequentially('X') - await expectLocator(input).toHaveText('helloX world') + await expect(input).toHaveText('helloX world') await waitForDraftWrite(observer, 'helloX world') await page.reload({waitUntil: 'domcontentloaded'}) const restored = composer(page) - await expectLocator(restored).toHaveText('helloX world', {timeout: 30_000}) + await expect(restored).toHaveText('helloX world', {timeout: 30_000}) await page.keyboard.type('Y') - await expectLocator(restored).toHaveText('helloXY world') + await expect(restored).toHaveText('helloXY world') observer.dispose() - await page.close() }) - it('Enter during IME composition never submits; Enter after the commit sends the composed text', async () => { - const {page, observer} = await observedPage() + test('Enter during IME composition never submits; Enter after the commit sends the composed text', async ({ + page: fixturePage, + }) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) const cdp = await page.context().newCDPSession(page) @@ -227,36 +226,34 @@ describe('the rich composer input in the live widget shadow DOM', () => { await input.pressSequentially('hello') await cdp.send('Input.imeSetComposition', {text: 'ん', selectionStart: 1, selectionEnd: 1}) await page.keyboard.press('Enter') - await expectLocator(input).toHaveText(/hello/) + await expect(input).toHaveText(/hello/) await cdp.send('Input.insertText', {text: 'ん'}) - await expectLocator(input).toHaveText('helloん') + await expect(input).toHaveText('helloん') const sent = waitForSend(observer) await page.keyboard.press('Enter') expect(await sent).toBe('helloん') - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByText('helloん', {exact: true})).toBeVisible() + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText('helloん', {exact: true})).toBeVisible() observer.dispose() - await page.close() }) - it('the send button submits the visible draft including pending composition text', async () => { - const {page, observer} = await observedPage() + test('the send button submits the visible draft including pending composition text', async ({page: fixturePage}) => { + const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) const cdp = await page.context().newCDPSession(page) await input.pressSequentially('committed draft') await cdp.send('Input.imeSetComposition', {text: 'か', selectionStart: 1, selectionEnd: 1}) - await expectLocator(input).toHaveText('committed draftか') + await expect(input).toHaveText('committed draftか') const sent = waitForSend(observer) await page.getByRole('button', {name: 'Send message'}).click() expect(await sent).toBe('committed draftか') - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByText('committed draftか', {exact: true})).toBeVisible() + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText('committed draftか', {exact: true})).toBeVisible() observer.dispose() - await page.close() }) }) diff --git a/packages/embed/test/composer-trigger-menu.it.test.ts b/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts similarity index 56% rename from packages/embed/test/composer-trigger-menu.it.test.ts rename to packages/embed/tests/e2e/composer-trigger-menu.it.test.ts index a22ab3f9a..3528240fa 100644 --- a/packages/embed/test/composer-trigger-menu.it.test.ts +++ b/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts @@ -1,6 +1,4 @@ -import {describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import type {Locator, Page} from 'playwright' +import {expect, test, type Locator, type Page} from '@playwright/test' import recorderServer from '@conciv/extension-recorder' import {setupWidgetSuite} from './helpers/suite.js' import {openPanel} from './helpers/panel.js' @@ -36,40 +34,35 @@ async function openComposer(page: Page): Promise { await openPanel(page) const input = composer(page) await input.click() - await expectLocator(input).toHaveText('') + await expect(input).toHaveText('') } async function expectActiveOption(page: Page, option: Locator): Promise { - await expectLocator(option).toBeVisible({timeout: 10_000}) - await expectLocator(option).toHaveAttribute('id', /.+/, {timeout: 10_000}) + await expect(option).toBeVisible({timeout: 10_000}) + await expect(option).toHaveAttribute('id', /.+/, {timeout: 10_000}) const optionId = await option.getAttribute('id') if (!optionId) throw new Error('the highlighted option rendered without an id') - await expectLocator(composer(page)).toHaveAttribute('aria-activedescendant', optionId, {timeout: 10_000}) + await expect(composer(page)).toHaveAttribute('aria-activedescendant', optionId, {timeout: 10_000}) } -describe('the composer trigger menu inside the live widget panel', () => { - it('groups commands by source with a visible header and a description per command', async () => { - const page = await suite.browser().newPage() +test.describe('the composer trigger menu inside the live widget panel', () => { + test('groups commands by source with a visible header and a description per command', async ({page}) => { await openComposer(page) await composer(page).pressSequentially('/co') const menu = commandMenu(page) - await expectLocator(menu.getByRole('option', {name: '/compact'})).toBeVisible({timeout: 10_000}) - await expectLocator(menu.getByRole('group', {name: 'Commands'})).toBeVisible() - await expectLocator(menu.getByRole('group', {name: 'MCP'})).toBeVisible() - await expectLocator(menu.getByRole('group', {name: 'Plugins'})).toBeVisible() - await expectLocator(menu.getByRole('option', {name: '/compact'})).toHaveAccessibleDescription( - 'Compact the conversation', - ) - await expectLocator(menu.getByRole('option', {name: '/mcp__probe__connect'})).toHaveAccessibleDescription( + await expect(menu.getByRole('option', {name: '/compact'})).toBeVisible({timeout: 10_000}) + await expect(menu.getByRole('group', {name: 'Commands'})).toBeVisible() + await expect(menu.getByRole('group', {name: 'MCP'})).toBeVisible() + await expect(menu.getByRole('group', {name: 'Plugins'})).toBeVisible() + await expect(menu.getByRole('option', {name: '/compact'})).toHaveAccessibleDescription('Compact the conversation') + await expect(menu.getByRole('option', {name: '/mcp__probe__connect'})).toHaveAccessibleDescription( 'Connect the probe server', ) - await expectLocator(menu.getByText('Compact the conversation')).toBeVisible() - await page.close() + await expect(menu.getByText('Compact the conversation')).toBeVisible() }) - it('moves the highlight with the arrow keys and commits the highlighted command with Enter', async () => { - const page = await suite.browser().newPage() + test('moves the highlight with the arrow keys and commits the highlighted command with Enter', async ({page}) => { await openComposer(page) const input = composer(page) await input.pressSequentially('/co') @@ -84,54 +77,50 @@ describe('the composer trigger menu inside the live widget panel', () => { await expectActiveOption(page, menu.getByRole('option', {name: '/config'})) await page.keyboard.press('Enter') - await expectLocator(menu).toBeHidden({timeout: 10_000}) - await expectLocator(input).toHaveText('/config ') - await page.close() + await expect(menu).toBeHidden({timeout: 10_000}) + await expect(input).toHaveText('/config ') }) - it('keeps the highlighted option inside the panel viewport while arrowing down a long list', async () => { - const page = await suite.browser().newPage() + test('keeps the highlighted option inside the panel viewport while arrowing down a long list', async ({page}) => { await openComposer(page) await composer(page).pressSequentially('/task-') const menu = commandMenu(page) const first = menu.getByRole('option', {name: '/task-00'}) await expectActiveOption(page, first) - await expectLocator(first).toBeInViewport() + await expect(first).toBeInViewport() const target = menu.getByRole('option', {name: '/task-30'}) for (let step = 0; step < 30; step += 1) await page.keyboard.press('ArrowDown') await expectActiveOption(page, target) - await expectLocator(target).toBeInViewport() - await expectLocator(first).not.toBeInViewport() + await expect(target).toBeInViewport() + await expect(first).not.toBeInViewport() await page.keyboard.press('Enter') - await expectLocator(composer(page)).toHaveText('/task-30 ') - await page.close() + await expect(composer(page)).toHaveText('/task-30 ') }) - it('groups tools in the mention menu and commits the highlighted tool with Enter', async () => { - const page = await suite.browser().newPage() + test('groups tools in the mention menu and commits the highlighted tool with Enter', async ({page}) => { await openComposer(page) const input = composer(page) await input.pressSequentially('@recording_') const menu = toolMenu(page) await expectActiveOption(page, menu.getByRole('option', {name: '@recording_start'})) - await expectLocator(menu.getByRole('group', {name: 'recorder'})).toBeVisible() - await expectLocator(menu.getByRole('option', {name: '@recording_start'})).toHaveAccessibleDescription( + await expect(menu.getByRole('group', {name: 'recorder'})).toBeVisible() + await expect(menu.getByRole('option', {name: '@recording_start'})).toHaveAccessibleDescription( /Start a marked recording/, ) await page.keyboard.press('ArrowDown') await expectActiveOption(page, menu.getByRole('option', {name: '@recording_stop'})) await page.keyboard.press('Enter') - await expectLocator(menu).toBeHidden({timeout: 10_000}) - await expectLocator(input).toHaveText('@recording_stop ') - await page.close() + await expect(menu).toBeHidden({timeout: 10_000}) + await expect(input).toHaveText('@recording_stop ') }) - it('never hands the keyboard to the menu: Tab closes it and the next keystroke still edits the composer', async () => { - const page = await suite.browser().newPage() + test('never hands the keyboard to the menu: Tab closes it and the next keystroke still edits the composer', async ({ + page, + }) => { await openComposer(page) const input = composer(page) await input.pressSequentially('/co') @@ -140,11 +129,10 @@ describe('the composer trigger menu inside the live widget panel', () => { await expectActiveOption(page, menu.getByRole('option', {name: '/compact'})) await page.keyboard.press('Tab') - await expectLocator(menu).toBeHidden({timeout: 10_000}) - await expectLocator(input).toBeFocused() + await expect(menu).toBeHidden({timeout: 10_000}) + await expect(input).toBeFocused() await input.pressSequentially('x') - await expectLocator(input).toHaveText('/cox') - await page.close() + await expect(input).toHaveText('/cox') }) }) diff --git a/packages/embed/test/connection-pool.it.test.ts b/packages/embed/tests/e2e/connection-pool.it.test.ts similarity index 63% rename from packages/embed/test/connection-pool.it.test.ts rename to packages/embed/tests/e2e/connection-pool.it.test.ts index 0e07488c2..73936b258 100644 --- a/packages/embed/test/connection-pool.it.test.ts +++ b/packages/embed/tests/e2e/connection-pool.it.test.ts @@ -1,36 +1,31 @@ -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type BrowserContext, type Page} from 'playwright' +import {expect, test, type BrowserContext, type Page} from '@playwright/test' import {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' -import {proxyTo, type ProxyCore} from './helpers/proxy.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' +import {proxyTo, type ProxyCore} from '../helpers/proxy.js' import {setNavigation} from './helpers/navigation.js' const ASSISTANT_TEXT = 'Hello from conciv' const SHARED_CONTEXT_TAB_COUNT = 6 const MOUNT_TIMEOUT_MS = 20_000 -let browser: Browser let kit: EmbedKit let core: ProxyCore let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT}) core = await proxyTo(kit.base) host = await serveHost(() => hostPage({apiBase: core.base, widget: '{"quickTerminal":false}'})) -}, 90_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await core.close() await kit.cleanup() }) -beforeEach(async () => { +test.beforeEach(async () => { expect(await setNavigation(kit, [{href: '/'}])).toBe(true) }) @@ -45,13 +40,13 @@ async function openTab(context: BrowserContext): Promise { }) const observer = observeRpc(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) return {page, observer, httpRpcUrls} } async function openPanel(page: Page): Promise { await page.getByRole('button', {name: 'Open conciv chat'}).click() - await expectLocator(page.getByRole('textbox', {name: 'Message the conciv agent'})).toBeVisible({ + await expect(page.getByRole('textbox', {name: 'Message the conciv agent'})).toBeVisible({ timeout: MOUNT_TIMEOUT_MS, }) } @@ -59,12 +54,13 @@ async function openPanel(page: Page): Promise { async function sendTurn(page: Page, text: string, expectedReplies: number): Promise { await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill(text) await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText(ASSISTANT_TEXT)).toHaveCount(expectedReplies, {timeout: MOUNT_TIMEOUT_MS}) + await expect(page.getByText(ASSISTANT_TEXT)).toHaveCount(expectedReplies, {timeout: MOUNT_TIMEOUT_MS}) } -describe('six widget tabs sharing one browserContext connection pool (newContext, not newPage, because the shared-context http connection limit is exactly what this gate measures)', () => { - it('gives every tab one rpc websocket, no rpc over http, and a working chat round trip in the last tab', async () => { - const context = await browser.newContext() +test.describe('six widget tabs sharing one browserContext connection pool (the per-test context fixture, not newPage, because the shared-context http connection limit is exactly what this gate measures)', () => { + test('gives every tab one rpc websocket, no rpc over http, and a working chat round trip in the last tab', async ({ + context, + }) => { const tabs: Tab[] = [] try { for (let index = 0; index < SHARED_CONTEXT_TAB_COUNT; index += 1) tabs.push(await openTab(context)) @@ -78,7 +74,6 @@ describe('six widget tabs sharing one browserContext connection pool (newContext for (const tab of tabs) expect(tab.httpRpcUrls).toEqual([]) } finally { for (const tab of tabs) tab.observer.dispose() - await context.close() } }) }) diff --git a/packages/embed/test/create-conciv.it.test.ts b/packages/embed/tests/e2e/create-conciv.it.test.ts similarity index 59% rename from packages/embed/test/create-conciv.it.test.ts rename to packages/embed/tests/e2e/create-conciv.it.test.ts index 96a6bddac..687c46698 100644 --- a/packages/embed/test/create-conciv.it.test.ts +++ b/packages/embed/tests/e2e/create-conciv.it.test.ts @@ -1,33 +1,27 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page, type WebSocket as PageWebSocket} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {handleHostPage, serveHost} from './helpers/host.js' +import {expect, test, type Page, type WebSocket as PageWebSocket} from '@playwright/test' +import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage, serveHost} from '../helpers/host.js' import {mountHandle, remountHandle, unmountHandle} from './helpers/handle.js' import {chatBox, openChatPanel, sendChatMessage} from './helpers/chat.js' -import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer' import {setNavigation} from './helpers/navigation.js' const ASSISTANT_TEXT = 'Hello from conciv' -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT}) host = await serveHost(() => handleHostPage()) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) -async function openPage(): Promise { - const page = await browser.newPage() +async function openPage(page: Page): Promise { await page.goto(host.base, {waitUntil: 'domcontentloaded'}) return page } @@ -53,28 +47,29 @@ function rpcSocket(page: Page): Promise { }) } -describe('createConciv lifecycle', () => { - it('mounts, unmounts, and remounts the widget', async () => { - const page = await openPage() +test.describe('createConciv lifecycle', () => { + test('mounts, unmounts, and remounts the widget', async ({page}) => { + test.setTimeout(120_000) + await openPage(page) await mountHandle(page, kit.base) - await expectLocator(fab(page)).toBeVisible({timeout: 30_000}) + await expect(fab(page)).toBeVisible({timeout: 30_000}) await unmountHandle(page) - await expectLocator(fab(page)).toHaveCount(0, {timeout: 30_000}) + await expect(fab(page)).toHaveCount(0, {timeout: 30_000}) await remountHandle(page) - await expectLocator(fab(page)).toBeVisible({timeout: 30_000}) - await page.close() + await expect(fab(page)).toBeVisible({timeout: 30_000}) }) - it('threads the mounted api base to extension surfaces when the host page has no pw-api-base meta', async () => { - const page = await openPage() + test('threads the mounted api base to extension surfaces when the host page has no pw-api-base meta', async ({ + page, + }) => { + await openPage(page) await mountHandle(page, kit.base) const probe = page.getByRole('status', {name: 'host api base probe'}) - await expectLocator(probe).toHaveText(kit.base, {timeout: 30_000}) - await page.close() + await expect(probe).toHaveText(kit.base, {timeout: 30_000}) }) - it('a second mount on an already-mounted handle is a no-op', async () => { - const page = await openPage() + test('a second mount on an already-mounted handle is a no-op', async ({page}) => { + await openPage(page) await page.evaluate((base) => { const el = document.createElement('div') document.body.appendChild(el) @@ -82,13 +77,12 @@ describe('createConciv lifecycle', () => { void handle.mount(el) void handle.mount(el) }, kit.base) - await expectLocator(fab(page)).toHaveCount(1, {timeout: 30_000}) + await expect(fab(page)).toHaveCount(1, {timeout: 30_000}) expect(await fab(page).count()).toBe(1) - await page.close() }) - it('unmount during mount leaves nothing behind', async () => { - const page = await openPage() + test('unmount during mount leaves nothing behind', async ({page}) => { + await openPage(page) await page.evaluate((base) => { const el = document.createElement('div') document.body.appendChild(el) @@ -96,46 +90,45 @@ describe('createConciv lifecycle', () => { void handle.mount(el) handle.unmount() }, kit.base) - await expectLocator(fab(page)).toHaveCount(0, {timeout: 30_000}) + await expect(fab(page)).toHaveCount(0, {timeout: 30_000}) expect(await page.evaluate(() => document.querySelector('[data-conciv-root]') === null)).toBe(true) - await page.close() }) - it('restores the host __TSR_ROUTER__ global on unmount', async () => { - const page = await openPage() + test('restores the host __TSR_ROUTER__ global on unmount', async ({page}) => { + await openPage(page) await page.evaluate(() => { Reflect.set(window, '__TSR_ROUTER__', {hostSentinel: true}) }) await mountHandle(page, kit.base) - await expectLocator(fab(page)).toBeVisible({timeout: 30_000}) + await expect(fab(page)).toBeVisible({timeout: 30_000}) await unmountHandle(page) const restored = await page.evaluate(() => { const value = Reflect.get(window, '__TSR_ROUTER__') return typeof value === 'object' && value !== null && 'hostSentinel' in value }) expect(restored).toBe(true) - await page.close() }) - it('unmounts cleanly with an open panel and a completed turn', async () => { - const page = await openPage() + test('unmounts cleanly with an open panel and a completed turn', async ({page}) => { + test.setTimeout(120_000) + await openPage(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) await mountHandle(page, kit.base) await fab(page).click() - await expectLocator(chatBox(page)).toBeVisible({timeout: 30_000}) + await expect(chatBox(page)).toBeVisible({timeout: 30_000}) await chatBox(page).fill('hello') await chatBox(page).press('Enter') - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) await unmountHandle(page) - await expectLocator(fab(page)).toHaveCount(0, {timeout: 30_000}) + await expect(fab(page)).toHaveCount(0, {timeout: 30_000}) expect(pageErrors).toEqual([]) - await page.close() }) - it('closes the tab rpc websocket on unmount and dials a fresh one on remount', async () => { + test('closes the tab rpc websocket on unmount and dials a fresh one on remount', async ({page}) => { + test.setTimeout(120_000) expect(await setNavigation(kit, [{href: '/'}])).toBe(true) - const page = await openPage() + await openPage(page) const observer = rpcObserverFor(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) @@ -144,20 +137,19 @@ describe('createConciv lifecycle', () => { const socket = await socketOpened await openChatPanel(page) await sendChatMessage(page, 'before the unmount') - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) const closed = closedWithin(socket, SOCKET_CLOSE_TIMEOUT_MS) await unmountHandle(page) - await expectLocator(fab(page)).toHaveCount(0, {timeout: 30_000}) + await expect(fab(page)).toHaveCount(0, {timeout: 30_000}) expect(await closed).toBe(true) await remountHandle(page) - await expectLocator(chatBox(page)).toBeVisible({timeout: 30_000}) + await expect(chatBox(page)).toBeVisible({timeout: 30_000}) const mark = observer.mark() await sendChatMessage(page, 'after the remount') const remounted = await observer.completed({path: ['chat', 'send'], since: mark, timeout: 30_000}) expect(remounted.transport).toBe('websocket') expect(pageErrors).toEqual([]) - await page.close() }) }) diff --git a/packages/embed/test/draft-selection.it.test.ts b/packages/embed/tests/e2e/draft-selection.it.test.ts similarity index 81% rename from packages/embed/test/draft-selection.it.test.ts rename to packages/embed/tests/e2e/draft-selection.it.test.ts index a31223a56..40ee5aaed 100644 --- a/packages/embed/test/draft-selection.it.test.ts +++ b/packages/embed/tests/e2e/draft-selection.it.test.ts @@ -1,13 +1,12 @@ -import {describe, expect, it} from 'vitest' +import {expect, test} from '@playwright/test' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' import {setupWidgetSuite} from './helpers/suite.js' import {openPanel} from './helpers/panel.js' const suite = setupWidgetSuite() -describe('draft persistence carries the caret offsets', () => { - it('persists the draft text with the caret position after the debounce', async () => { - const page = await suite.browser().newPage() +test.describe('draft persistence carries the caret offsets', () => { + test('persists the draft text with the caret position after the debounce', async ({page}) => { const observer = observeRpc(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) @@ -29,6 +28,5 @@ describe('draft persistence carries the caret offsets', () => { selectionEnd: 4, }) observer.dispose() - await page.close() }) }) diff --git a/packages/embed/test/element-capture.it.test.ts b/packages/embed/tests/e2e/element-capture.it.test.ts similarity index 71% rename from packages/embed/test/element-capture.it.test.ts rename to packages/embed/tests/e2e/element-capture.it.test.ts index 8f7646e20..2002d9324 100644 --- a/packages/embed/test/element-capture.it.test.ts +++ b/packages/embed/tests/e2e/element-capture.it.test.ts @@ -1,9 +1,7 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import type {SessionCaptures} from '@conciv/protocol/element-capture-types' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' import {openPagePlaneHost} from './helpers/page-plane-host.js' import {openPanel} from './helpers/panel.js' @@ -18,25 +16,22 @@ const HOST_BODY = ` ` -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} let sessionId: string -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit() sessionId = await kit.session() host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}', body: HOST_BODY})) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) -const openHostPage = (): Promise => openPagePlaneHost(browser, host.base) +const openHostPage = (page: Page): Promise => openPagePlaneHost(page, host.base) function rowIdentities(stored: SessionCaptures): string[] { return stored.captures.map((row) => `${row.toolCallId}:${row.kind}:${JSON.stringify(row.capture)}`).toSorted() @@ -45,13 +40,13 @@ function rowIdentities(stored: SessionCaptures): string[] { async function sendAndRevealThought(page: Page, message: string): Promise { await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill(message) await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) await page.getByText('Chain of Thought').last().click() } -describe('a page tool run through the widget stores a frozen picture of the element it touched', () => { - it('keeps the pre-edit element after the page flips its theme and deletes the node', async () => { - const page = await openHostPage() +test.describe('a page tool run through the widget stores a frozen picture of the element it touched', () => { + test('keeps the pre-edit element after the page flips its theme and deletes the node', async ({page}) => { + await openHostPage(page) await kit.callTool('page.settext', {selector: '#prose', text: 'rewritten by the agent'}, sessionId) await page.evaluate(() => { @@ -73,11 +68,10 @@ describe('a page tool run through the widget stores a frozen picture of the elem expect(JSON.stringify(after?.capture.node)).not.toContain('theme-dark') const cssBundleId = before?.capture.cssBundleId expect(cssBundleId === undefined ? '' : stored.cssBundles[cssBundleId]).toContain('.panel .cta') - await page.close() - }, 60_000) + }) - it('never lets a password value reach the stored capture or the tool result', async () => { - const page = await openHostPage() + test('never lets a password value reach the stored capture or the tool result', async ({page}) => { + await openHostPage(page) const result = await kit.callTool('page.fill', {selector: '#secret', value: 'typed by the agent'}, sessionId) const stored: SessionCaptures = await kit.rpc.captures.list({sessionId}) const secretCaptures = stored.captures.filter((row) => row.capture.descriptor.selectorPath.includes('secret')) @@ -85,40 +79,38 @@ describe('a page tool run through the widget stores a frozen picture of the elem for (const row of secretCaptures) expect(JSON.stringify(row.capture)).not.toContain(PASSWORD) expect(JSON.stringify(result)).not.toContain(PASSWORD) expect(JSON.stringify(stored)).not.toContain(PASSWORD) - await page.close() - }, 60_000) + }) - it('hands the harness a result with no capture in it', async () => { - const page = await openHostPage() + test('hands the harness a result with no capture in it', async ({page}) => { + await openHostPage(page) const result = await kit.callTool('page.click', {selector: '#cta'}, sessionId) expect(JSON.stringify(result)).not.toContain('cssBundleId') expect(JSON.stringify(result)).not.toContain('selectorPath') expect(JSON.stringify(result)).not.toContain('data-rr-target') - await page.close() - }, 60_000) + }) - it('takes no capture for a read verb', async () => { - const page = await openHostPage() + test('takes no capture for a read verb', async ({page}) => { + await openHostPage(page) const before: SessionCaptures = await kit.rpc.captures.list({sessionId}) await kit.callTool('page.text', {selector: '#cta'}, sessionId) const after: SessionCaptures = await kit.rpc.captures.list({sessionId}) expect(rowIdentities(after)).toEqual(rowIdentities(before)) - await page.close() - }, 60_000) + }) - it('renders a page verb run through real code mode with its element preview ready in the widget', async () => { - const page = await openHostPage() + test('renders a page verb run through real code mode with its element preview ready in the widget', async ({ + page, + }) => { + await openHostPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', { typescriptCode: "await external_page_settext({selector: '#prose', text: 'rendered through the pipeline'})", }) await sendAndRevealThought(page, 'rewrite the prose through code mode') const settextCard = page.getByRole('button', {name: /Set the text/}) - await expectLocator(settextCard).toBeVisible({timeout: 30_000}) + await expect(settextCard).toBeVisible({timeout: 30_000}) await settextCard.click() const preview = page.getByRole('img', {name: 'rendered through the pipeline'}) - await expectLocator(preview).toBeVisible({timeout: 30_000}) - await expectLocator(preview).not.toHaveAttribute('aria-busy') - await page.close() - }, 60_000) + await expect(preview).toBeVisible({timeout: 30_000}) + await expect(preview).not.toHaveAttribute('aria-busy') + }) }) diff --git a/packages/embed/test/embed.it.test.ts b/packages/embed/tests/e2e/embed.it.test.ts similarity index 57% rename from packages/embed/test/embed.it.test.ts rename to packages/embed/tests/e2e/embed.it.test.ts index 1e1696c91..d624e61d1 100644 --- a/packages/embed/test/embed.it.test.ts +++ b/packages/embed/tests/e2e/embed.it.test.ts @@ -1,8 +1,6 @@ -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' +import {expect, test, type Page} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer' import { currentHref, @@ -30,13 +28,11 @@ const LONG_HOST_BODY = `

Host site

${Array.from( (_unused, index) => `

Host paragraph ${index + 60} below the heading.

`, ).join('')}` -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} let longHost: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT, models: HARNESS_MODELS}) host = await serveHost((url) => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}', backdrop: url.searchParams.get('backdrop')}), @@ -44,16 +40,15 @@ beforeAll(async () => { longHost = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}', body: LONG_HOST_BODY}), ) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await longHost.close() await kit.cleanup() }) -beforeEach(async () => { +test.beforeEach(async () => { expect(await setNavigation(kit, [{href: '/'}])).toBe(true) }) @@ -62,8 +57,8 @@ function observedPage(page: Page): Page { return page } -async function openPage(): Promise { - const page = observedPage(await browser.newPage()) +async function openPage(page: Page): Promise { + observedPage(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) return page } @@ -71,15 +66,15 @@ async function openPage(): Promise { async function sendAndRevealThought(page: Page, message: string): Promise { await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill(message) await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) await page.getByText('Chain of Thought').last().click() } -describe('embed boots the conciv app against a real core', () => { - it('canonicalizes a restored panel route that carries a raw harness session id', async () => { +test.describe('embed boots the conciv app against a real core', () => { + test('canonicalizes a restored panel route that carries a raw harness session id', async ({page}) => { const rawHarnessId = '43548fd1-0000-4220-acf0-014b10b5815f' expect(await setNavigation(kit, [{href: `/panel/${rawHarnessId}`}])).toBe(true) - const page = observedPage(await browser.newPage()) + observedPage(page) const canonicalized = waitForNavigationWrite(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await canonicalized @@ -87,11 +82,10 @@ describe('embed boots the conciv app against a real core', () => { const adopted = await kit.rpc.sessions.resolve({id: rawHarnessId}) const persisted = await kit.rpc.navigation.get() expect(persisted?.entries[persisted.index]?.href).toBe(`/panel/${adopted.sessionId}`) - await page.close() }) - it('a widget navigation write that lands after a newer one loses, even in flight', async () => { - const page = observedPage(await browser.newPage()) + test('a widget navigation write that lands after a newer one loses, even in flight', async ({page}) => { + observedPage(page) const held = await holdFirstNavigationWrite(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(page) @@ -103,12 +97,14 @@ describe('embed boots the conciv app against a real core', () => { await landed expect(await currentHref(kit)).toBe('/reset-while-the-widget-write-is-in-flight') - await page.close() }) - it('a reloaded page outranks the previous page in-flight write when both clocks read the same', async () => { + test('a reloaded page outranks the previous page in-flight write when both clocks read the same', async ({ + page, + context, + }) => { const frozen = Date.now() - const before = observedPage(await browser.newPage()) + const before = observedPage(page) await freezeClock(before, frozen) const held = await holdFirstNavigationWrite(before) expect((await kit.rpc.navigation.set({entries: [{href: '/'}], index: 0, updatedAt: frozen + 5_000})).applied).toBe( @@ -118,7 +114,7 @@ describe('embed boots the conciv app against a real core', () => { await openPanel(before) await held.arrived - const after = observedPage(await browser.newPage()) + const after = observedPage(await context.newPage()) await freezeClock(after, frozen) await after.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(after) @@ -132,48 +128,44 @@ describe('embed boots the conciv app against a real core', () => { await landed expect(await currentHref(kit)).toContain('/terminal') - await before.close() - await after.close() }) - it('fab close is a shutter: reopening restores the same view without touching history', async () => { - const page = await openPage() + test('fab close is a shutter: reopening restores the same view without touching history', async ({page}) => { + await openPage(page) await openPanel(page) await page.getByRole('tab', {name: 'Terminal'}).click() - await expectLocator(page.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { + await expect(page.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { timeout: 30_000, }) await page.getByRole('button', {name: 'Minimize conciv chat'}).click() - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) const reopened = waitForNavigationWrite(page) await page.getByRole('button', {name: 'Open conciv chat'}).click() - await expectLocator(page.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { + await expect(page.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { timeout: 30_000, }) await reopened const persisted = await kit.rpc.navigation.get() expect(persisted?.entries.filter((entry) => entry.href.includes('/panel/'))).toHaveLength(1) - await page.close() }) - it('a reload restores the panel open on the same view', async () => { - const first = await openPage() + test('a reload restores the panel open on the same view', async ({page, context}) => { + const first = await openPage(page) await openPanel(first) const switched = waitForNavigationWriteCarrying(first, '/terminal') await first.getByRole('tab', {name: 'Terminal'}).click() await switched expect(await currentHref(kit)).toMatch(/\/terminal\?.*open=true/) await first.close() - const second = await openPage() - await expectLocator(second.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) - await expectLocator(second.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { + const second = await openPage(await context.newPage()) + await expect(second.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) + await expect(second.getByRole('tab', {name: 'Terminal'})).toHaveAttribute('aria-selected', 'true', { timeout: 30_000, }) - await second.close() }) - it('a reload after closing the panel boots shut', async () => { - const first = observedPage(await browser.newPage()) + test('a reload after closing the panel boots shut', async ({page, context}) => { + const first = observedPage(page) const opened = waitForNavigationWrite(first) await first.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(first) @@ -184,29 +176,27 @@ describe('embed boots the conciv app against a real core', () => { await shut expect(await currentHref(kit)).not.toContain('open=true') await first.close() - const second = await openPage() - await expectLocator(second.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) + const second = await openPage(await context.newPage()) + await expect(second.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) expect(await second.getByRole('dialog', {name: 'conciv chat agent'}).count()).toBe(0) - await second.close() }) - it('renders the fab instantly and opens the panel', async () => { - const page = await openPage() - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) + test('renders the fab instantly and opens the panel', async ({page}) => { + await openPage(page) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) await openPanel(page) - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible() - await page.close() + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible() }) - it('opening and closing the panel keeps the host page where the reader scrolled it', async () => { - const page = observedPage(await browser.newPage()) + test('opening and closing the panel keeps the host page where the reader scrolled it', async ({page}) => { + observedPage(page) await page.goto(longHost.base, {waitUntil: 'domcontentloaded'}) const heading = page.getByRole('heading', {name: HOST_HEADING}) const headingTop = async () => (await heading.boundingBox())?.y ?? Number.NaN const unscrolled = await headingTop() await page.mouse.wheel(0, unscrolled - 200) - await expectLocator(heading).toBeInViewport({timeout: 30_000}) + await expect(heading).toBeInViewport({timeout: 30_000}) const readerPosition = await headingTop() expect(readerPosition).toBeLessThan(unscrolled - 1000) @@ -214,44 +204,41 @@ describe('embed boots the conciv app against a real core', () => { expect(await headingTop()).toBe(readerPosition) await page.getByRole('textbox', {name: 'Message the conciv agent'}).press('Escape') - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) expect(await headingTop()).toBe(readerPosition) - await page.close() }) - it('sends a message and renders the assistant reply from the fake harness', async () => { - const page = await openPage() + test('sends a message and renders the assistant reply from the fake harness', async ({page}) => { + await openPage(page) await openPanel(page) await sendMessage(page, 'hi there', ASSISTANT_TEXT) - await page.close() }) - it('shows Stop instead of Send while a run is streaming; typing stays enabled', async () => { - const page = await openPage() + test('shows Stop instead of Send while a run is streaming; typing stays enabled', async ({page}) => { + await openPage(page) await openPanel(page) kit.harness.script.hold() const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await input.fill('long question') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByRole('button', {name: 'Stop generating'})).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Stop generating'})).toBeVisible({timeout: 30_000}) await input.fill('still typing while it runs') - await expectLocator(input).toHaveText('still typing while it runs') + await expect(input).toHaveText('still typing while it runs') kit.harness.script.release() - await expectLocator(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) - await page.close() + await expect(page.getByRole('button', {name: 'Stop generating'})).toBeHidden({timeout: 30_000}) }) - it('Escape closes the panel back to the fab', async () => { - const page = await openPage() + test('Escape closes the panel back to the fab', async ({page}) => { + await openPage(page) await openPanel(page) await page.getByRole('textbox', {name: 'Message the conciv agent'}).press('Escape') - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible() - await page.close() + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible() }) - it('renders the conciv_ui blocking card from the tool part and answers via uiReply', async () => { - const page = await openPage() + test('renders the conciv_ui blocking card from the tool part and answers via uiReply', async ({page}) => { + test.setTimeout(120_000) + await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', { typescriptCode: "return await external_conciv_ui({kind: 'confirm', question: 'Proceed with the change?'})", @@ -259,50 +246,50 @@ describe('embed boots the conciv app against a real core', () => { const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await input.fill('ask me something') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText('Proceed with the change?')).toBeVisible({timeout: 30_000}) + await expect(page.getByText('Proceed with the change?')).toBeVisible({timeout: 30_000}) await page.getByRole('button', {name: 'Approve'}).click() - await expectLocator(page.getByRole('status').getByText('yes')).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByRole('button', {name: 'Approve'})).toBeHidden({timeout: 30_000}) - await page.close() + await expect(page.getByRole('status').getByText('yes')).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Approve'})).toBeHidden({timeout: 30_000}) }) - it('renders catalog and code-mode parts without blanking the transcript', async () => { - const page = await openPage() + test('renders catalog and code-mode parts without blanking the transcript', async ({page}) => { + await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('catalog', {search: 'weather'}, {blocking: false}) kit.harness.script.scriptCustomEvent('code_mode:console', {stream: 'stdout', text: 'hello from code mode'}) const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await input.fill('discover and run some tools') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) const announced = await page.getByRole('alert').allTextContents() expect(announced.every((text) => text.trim() === '')).toBe(true) - await page.close() }) - it('renders the new tool cards for results that do not match their payload schema', async () => { - const page = await openPage() + test('renders the new tool cards for results that do not match their payload schema', async ({page}) => { + test.setTimeout(120_000) + await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', {typescriptCode: 'return 1'}, {blocking: false}) kit.harness.script.scriptToolCall('catalog', {search: 'weather'}, {blocking: false}) const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await sendAndRevealThought(page, 'run some code') - await expectLocator(page.getByRole('button', {name: /run code return 1/})).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByText('return 1').first()).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('button', {name: /run code return 1/})).toBeVisible({timeout: 30_000}) + await expect(page.getByText('return 1').first()).toBeVisible({timeout: 30_000}) await sendAndRevealThought(page, 'now check the catalog') - await expectLocator(page.getByText('Capability catalog').last()).toBeVisible({timeout: 30_000}) - await expectLocator(input).toHaveText('') + await expect(page.getByText('Capability catalog').last()).toBeVisible({timeout: 30_000}) + await expect(input).toHaveText('') const announced = await page.getByRole('alert').allTextContents() expect(announced.every((text) => text.trim() === '')).toBe(true) - await page.close() }) }) -describe('embed at a phone viewport', () => { +test.describe('embed at a phone viewport', () => { + test.use({viewport: PHONE_VIEWPORT}) + let phoneKit: EmbedKit let phoneHost: {base: string; close: () => Promise} - beforeAll(async () => { + test.beforeAll(async () => { phoneKit = await bootEmbedKit({text: ASSISTANT_TEXT, models: HARNESS_MODELS}) phoneHost = await serveHost((url) => hostPage({ @@ -311,15 +298,14 @@ describe('embed at a phone viewport', () => { backdrop: url.searchParams.get('backdrop'), }), ) - }, 60_000) + }) - afterAll(async () => { + test.afterAll(async () => { await phoneHost.close() await phoneKit.cleanup() }) - it('paints an opaque sheet so the host page never shows through', async () => { - const page = await browser.newPage({viewport: PHONE_VIEWPORT}) + test('paints an opaque sheet so the host page never shows through', async ({page}) => { const shootOver = async (backdrop: string): Promise => { expect(await setNavigation(phoneKit, [{href: '/'}])).toBe(true) await page.goto(`${phoneHost.base}/?backdrop=${backdrop}`, {waitUntil: 'domcontentloaded'}) @@ -331,48 +317,47 @@ describe('embed at a phone viewport', () => { expect(repeated.equals(patterned)).toBe(true) const inverted = await shootOver('dark-stripes') expect(inverted.equals(patterned)).toBe(true) - await page.close() }) - it('opens as a full-screen sheet with the launcher hidden and the composer reachable', async () => { - const page = await browser.newPage({viewport: PHONE_VIEWPORT}) + test('opens as a full-screen sheet with the launcher hidden and the composer reachable', async ({page}) => { + test.setTimeout(120_000) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(page) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toHaveCount(0, {timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toHaveCount(0, {timeout: 30_000}) await sendMessage(page, 'hi there', ASSISTANT_TEXT) await page.getByRole('button', {name: 'Close chat'}).click() - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) - await page.close() + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) }) - it('keeps Stop and Send inside the sheet on a narrow phone while a run streams', async () => { - const page = await browser.newPage({viewport: {width: 320, height: 800}}) - await page.goto(host.base, {waitUntil: 'domcontentloaded'}) - await openPanel(page) - kit.harness.script.hold() - const send = page.getByRole('button', {name: 'Send message'}) - const stop = page.getByRole('button', {name: 'Stop generating'}) - await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill('a question that keeps running') - await send.click() - await expectLocator(stop).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByRole('button', {name: 'Select model'})).toBeInViewport({ratio: 1, timeout: 5_000}) - await expectLocator(stop).toBeInViewport({ratio: 1, timeout: 5_000}) - await expectLocator(send).toBeInViewport({ratio: 1, timeout: 5_000}) - kit.harness.script.release() - await expectLocator(stop).toBeHidden({timeout: 30_000}) - await page.close() + test.describe('a narrower phone width', () => { + test.use({viewport: {width: 320, height: 800}}) + + test('keeps Stop and Send inside the sheet on a narrow phone while a run streams', async ({page}) => { + await page.goto(host.base, {waitUntil: 'domcontentloaded'}) + await openPanel(page) + kit.harness.script.hold() + const send = page.getByRole('button', {name: 'Send message'}) + const stop = page.getByRole('button', {name: 'Stop generating'}) + await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill('a question that keeps running') + await send.click() + await expect(stop).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('button', {name: 'Select model'})).toBeInViewport({ratio: 1, timeout: 5_000}) + await expect(stop).toBeInViewport({ratio: 1, timeout: 5_000}) + await expect(send).toBeInViewport({ratio: 1, timeout: 5_000}) + kit.harness.script.release() + await expect(stop).toBeHidden({timeout: 30_000}) + }) }) }) -describe('embed settings', () => { - it('modal disabled renders no fab', async () => { +test.describe('embed settings', () => { + test('modal disabled renders no fab', async ({page}) => { const disabledHost = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"modal": false}'})) - const page = observedPage(await browser.newPage()) + observedPage(page) await page.goto(disabledHost.base, {waitUntil: 'domcontentloaded'}) await page.getByRole('status').waitFor({state: 'attached', timeout: 15_000}) expect(await page.getByRole('button', {name: 'Open conciv chat'}).count()).toBe(0) - await page.close() await disabledHost.close() }) }) diff --git a/packages/embed/test/forced-drop.it.test.ts b/packages/embed/tests/e2e/forced-drop.it.test.ts similarity index 66% rename from packages/embed/test/forced-drop.it.test.ts rename to packages/embed/tests/e2e/forced-drop.it.test.ts index 5ffd9b2ec..ce8f3e050 100644 --- a/packages/embed/test/forced-drop.it.test.ts +++ b/packages/embed/tests/e2e/forced-drop.it.test.ts @@ -1,9 +1,7 @@ -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' -import {proxyTo, type ProxyCore} from './helpers/proxy.js' +import {expect, test, type Page} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' +import {proxyTo, type ProxyCore} from '../helpers/proxy.js' import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer' import {setNavigation} from './helpers/navigation.js' @@ -12,26 +10,23 @@ const FIRST_TEXT = 'first turn before the drop' const SECOND_TEXT = 'second turn after the drop' const MOUNT_TIMEOUT_MS = 20_000 -let browser: Browser let kit: EmbedKit let core: ProxyCore let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT}) core = await proxyTo(kit.base) host = await serveHost(() => hostPage({apiBase: core.base, widget: '{"quickTerminal":false}'})) -}, 90_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await core.close() await kit.cleanup() }) -beforeEach(async () => { +test.beforeEach(async () => { expect(await setNavigation(kit, [{href: '/'}])).toBe(true) }) @@ -40,21 +35,22 @@ async function sendTurn(page: Page, text: string): Promise { await page.getByRole('button', {name: 'Send message'}).click() } -describe('chat survives a forced websocket drop', () => { - it('re-subscribes and completes the next turn on a fresh socket after the core drops every connection', async () => { - const page = await browser.newPage() +test.describe('chat survives a forced websocket drop', () => { + test('re-subscribes and completes the next turn on a fresh socket after the core drops every connection', async ({ + page, + }) => { const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) const observer = rpcObserverFor(page) try { await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await page.getByRole('button', {name: 'Open conciv chat'}).click() - await expectLocator(page.getByRole('textbox', {name: 'Message the conciv agent'})).toBeVisible({ + await expect(page.getByRole('textbox', {name: 'Message the conciv agent'})).toBeVisible({ timeout: MOUNT_TIMEOUT_MS, }) await sendTurn(page, FIRST_TEXT) - await expectLocator(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: MOUNT_TIMEOUT_MS}) + await expect(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: MOUNT_TIMEOUT_MS}) const socketsBefore = observer.socketCount() const mark = observer.mark() @@ -69,7 +65,6 @@ describe('chat survives a forced websocket drop', () => { expect(pageErrors).toEqual([]) } finally { observer.dispose() - await page.close() } }) }) diff --git a/packages/embed/test/helpers/chat.ts b/packages/embed/tests/e2e/helpers/chat.ts similarity index 61% rename from packages/embed/test/helpers/chat.ts rename to packages/embed/tests/e2e/helpers/chat.ts index e151bcee4..d9464f569 100644 --- a/packages/embed/test/helpers/chat.ts +++ b/packages/embed/tests/e2e/helpers/chat.ts @@ -1,5 +1,4 @@ -import type {Page} from 'playwright' -import {expect as expectLocator} from 'playwright/test' +import {expect, type Page} from '@playwright/test' const PANEL_TIMEOUT_MS = 30_000 @@ -8,9 +7,9 @@ export function chatBox(page: Page) { } export async function openChatPanel(page: Page): Promise { - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: PANEL_TIMEOUT_MS}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: PANEL_TIMEOUT_MS}) await page.getByRole('button', {name: 'Open conciv chat'}).click() - await expectLocator(chatBox(page)).toBeVisible({timeout: PANEL_TIMEOUT_MS}) + await expect(chatBox(page)).toBeVisible({timeout: PANEL_TIMEOUT_MS}) } export async function sendChatMessage(page: Page, text: string): Promise { diff --git a/packages/embed/test/helpers/handle.ts b/packages/embed/tests/e2e/helpers/handle.ts similarity index 94% rename from packages/embed/test/helpers/handle.ts rename to packages/embed/tests/e2e/helpers/handle.ts index ac4298d0f..540acac97 100644 --- a/packages/embed/test/helpers/handle.ts +++ b/packages/embed/tests/e2e/helpers/handle.ts @@ -1,4 +1,4 @@ -import type {Page} from 'playwright' +import type {Page} from '@playwright/test' export async function mountHandle(page: Page, apiBase: string): Promise { await page.evaluate((base) => { diff --git a/packages/embed/test/helpers/native-bridge.ts b/packages/embed/tests/e2e/helpers/native-bridge.ts similarity index 95% rename from packages/embed/test/helpers/native-bridge.ts rename to packages/embed/tests/e2e/helpers/native-bridge.ts index da171264b..4cc4f469a 100644 --- a/packages/embed/test/helpers/native-bridge.ts +++ b/packages/embed/tests/e2e/helpers/native-bridge.ts @@ -1,4 +1,4 @@ -import type {Page} from 'playwright' +import type {Page} from '@playwright/test' import {PageToNativeSchema, type PageToNativeMessage} from '@conciv/extension-ios/bridge' export type NativeBridge = { diff --git a/packages/embed/test/helpers/navigation.ts b/packages/embed/tests/e2e/helpers/navigation.ts similarity index 97% rename from packages/embed/test/helpers/navigation.ts rename to packages/embed/tests/e2e/helpers/navigation.ts index ca46c9b5e..afccb145c 100644 --- a/packages/embed/test/helpers/navigation.ts +++ b/packages/embed/tests/e2e/helpers/navigation.ts @@ -1,8 +1,8 @@ -import type {Page, Route, WebSocketRoute} from 'playwright' +import type {Page, Route, WebSocketRoute} from '@playwright/test' import type {NavigationEntry} from '@conciv/protocol/chat-types' import {rpcObserverFor, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' import {decodeRpcFrame} from '@conciv/extension-testkit/rpc-frames' -import type {EmbedKit} from './boot.js' +import type {EmbedKit} from '../../helpers/boot.js' export const NAVIGATION_SET: readonly string[] = ['navigation', 'set'] diff --git a/packages/embed/test/helpers/page-plane-host.ts b/packages/embed/tests/e2e/helpers/page-plane-host.ts similarity index 63% rename from packages/embed/test/helpers/page-plane-host.ts rename to packages/embed/tests/e2e/helpers/page-plane-host.ts index 7a59c1ee6..edc8f27e1 100644 --- a/packages/embed/test/helpers/page-plane-host.ts +++ b/packages/embed/tests/e2e/helpers/page-plane-host.ts @@ -1,8 +1,7 @@ -import type {Browser, Page} from 'playwright' +import type {Page} from '@playwright/test' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' -export async function openPagePlaneHost(browser: Browser, base: string): Promise { - const page = await browser.newPage() +export async function openPagePlaneHost(page: Page, base: string): Promise { const observer = observeRpc(page) try { const subscribed = observer.completed({path: ['page', 'queries'], timeout: 30_000}) @@ -10,9 +9,6 @@ export async function openPagePlaneHost(browser: Browser, base: string): Promise await page.waitForFunction(() => '__CONCIV_PAGE_DRIVER__' in window, undefined, {timeout: 30_000}) await subscribed return page - } catch (error) { - await page.close().catch(() => undefined) - throw error } finally { observer.dispose() } diff --git a/packages/embed/test/helpers/panel.ts b/packages/embed/tests/e2e/helpers/panel.ts similarity index 62% rename from packages/embed/test/helpers/panel.ts rename to packages/embed/tests/e2e/helpers/panel.ts index e57515129..48f3a4674 100644 --- a/packages/embed/test/helpers/panel.ts +++ b/packages/embed/tests/e2e/helpers/panel.ts @@ -1,16 +1,15 @@ -import {expect as expectLocator} from 'playwright/test' -import type {Page} from 'playwright' +import {expect, type Page} from '@playwright/test' export async function openPanel(page: Page): Promise { const composer = page.getByRole('textbox', {name: 'Message the conciv agent'}) const opener = page.getByRole('button', {name: 'Open conciv chat'}) - await expectLocator(composer.or(opener)).toBeVisible({timeout: 30_000}) + await expect(composer.or(opener)).toBeVisible({timeout: 30_000}) if (!(await composer.isVisible())) await opener.click() - await expectLocator(composer).toBeVisible({timeout: 30_000}) + await expect(composer).toBeVisible({timeout: 30_000}) } export async function sendMessage(page: Page, text: string, reply: string): Promise { await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill(text) await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText(reply).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText(reply).first()).toBeVisible({timeout: 30_000}) } diff --git a/packages/embed/test/helpers/probe-server.ts b/packages/embed/tests/e2e/helpers/probe-server.ts similarity index 100% rename from packages/embed/test/helpers/probe-server.ts rename to packages/embed/tests/e2e/helpers/probe-server.ts diff --git a/packages/embed/test/helpers/probe-suite.ts b/packages/embed/tests/e2e/helpers/probe-suite.ts similarity index 55% rename from packages/embed/test/helpers/probe-suite.ts rename to packages/embed/tests/e2e/helpers/probe-suite.ts index 2d3183b2a..f53d447f0 100644 --- a/packages/embed/test/helpers/probe-suite.ts +++ b/packages/embed/tests/e2e/helpers/probe-suite.ts @@ -1,34 +1,28 @@ -import {afterAll, beforeAll} from 'vitest' -import {chromium, type Browser} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './boot.js' -import {serveHost, wsProbeHostPage} from './host.js' +import {test} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../../helpers/boot.js' +import {serveHost, wsProbeHostPage} from '../../helpers/host.js' export type ProbeSuite = { - browser: () => Browser kit: () => EmbedKit host: () => {base: string; close: () => Promise} socketUrl: () => string } export function setupWsProbeSuite(): ProbeSuite { - let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} - beforeAll(async () => { - browser = await chromium.launch() + test.beforeAll(async () => { kit = await bootEmbedKit() host = await serveHost(() => wsProbeHostPage()) - }, 60_000) + }) - afterAll(async () => { - await browser.close() + test.afterAll(async () => { await host.close() await kit.cleanup() }) return { - browser: () => browser, kit: () => kit, host: () => host, socketUrl: () => `${kit.wsBase}/rpc-ws`, diff --git a/packages/embed/test/helpers/suite.ts b/packages/embed/tests/e2e/helpers/suite.ts similarity index 52% rename from packages/embed/test/helpers/suite.ts rename to packages/embed/tests/e2e/helpers/suite.ts index e2283174d..8193a44db 100644 --- a/packages/embed/test/helpers/suite.ts +++ b/packages/embed/tests/e2e/helpers/suite.ts @@ -1,30 +1,25 @@ -import {afterAll, beforeAll} from 'vitest' -import {chromium, type Browser} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './boot.js' -import {hostPage, serveHost} from './host.js' +import {test} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../../helpers/boot.js' +import {hostPage, serveHost} from '../../helpers/host.js' export type WidgetSuite = { - browser: () => Browser kit: () => EmbedKit host: () => {base: string; close: () => Promise} } export function setupWidgetSuite(options: Parameters[0] = {}): WidgetSuite { - let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} - beforeAll(async () => { - browser = await chromium.launch() + test.beforeAll(async () => { kit = await bootEmbedKit(options) host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}'})) - }, 60_000) + }) - afterAll(async () => { - await browser.close() + test.afterAll(async () => { await host.close() await kit.cleanup() }) - return {browser: () => browser, kit: () => kit, host: () => host} + return {kit: () => kit, host: () => host} } diff --git a/packages/embed/test/model-selector.it.test.ts b/packages/embed/tests/e2e/model-selector.it.test.ts similarity index 53% rename from packages/embed/test/model-selector.it.test.ts rename to packages/embed/tests/e2e/model-selector.it.test.ts index 81f497967..815fc3df2 100644 --- a/packages/embed/test/model-selector.it.test.ts +++ b/packages/embed/tests/e2e/model-selector.it.test.ts @@ -1,5 +1,4 @@ -import {describe, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' +import {expect, test} from '@playwright/test' import {failRpcCalls} from '@conciv/extension-testkit/rpc-fault' import {setupWidgetSuite} from './helpers/suite.js' import {openPanel} from './helpers/panel.js' @@ -11,20 +10,20 @@ const HARNESS_MODELS = [ const suite = setupWidgetSuite({text: 'Hello from conciv', models: HARNESS_MODELS}) -describe('model selector error path', () => { - it('offers a retry when meta.models fails and recovers on retry', async () => { - const page = await suite.browser().newPage({viewport: {width: 900, height: 760}}) +test.describe('model selector error path', () => { + test.use({viewport: {width: 900, height: 760}}) + + test('offers a retry when meta.models fails and recovers on retry', async ({page}) => { const models = await failRpcCalls(page, {path: ['meta', 'models']}) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) const retry = page.getByRole('button', {name: 'Retry loading models'}) - await expectLocator(retry).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByText('Couldn’t load models').first()).toBeVisible({timeout: 30_000}) + await expect(retry).toBeVisible({timeout: 30_000}) + await expect(page.getByText('Couldn’t load models').first()).toBeVisible({timeout: 30_000}) models.repair() await retry.click() - await expectLocator(page.getByRole('button', {name: 'Select model'})).toBeVisible({timeout: 30_000}) - await page.close() + await expect(page.getByRole('button', {name: 'Select model'})).toBeVisible({timeout: 30_000}) }) }) diff --git a/packages/embed/test/native-live-region.it.test.ts b/packages/embed/tests/e2e/native-live-region.it.test.ts similarity index 78% rename from packages/embed/test/native-live-region.it.test.ts rename to packages/embed/tests/e2e/native-live-region.it.test.ts index d169b46fd..44a4f15c6 100644 --- a/packages/embed/test/native-live-region.it.test.ts +++ b/packages/embed/tests/e2e/native-live-region.it.test.ts @@ -1,27 +1,22 @@ import {fileURLToPath} from 'node:url' -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit' import type {PageToNativeMessage} from '@conciv/extension-ios/bridge' import {captureNativePosts, installNativeStub} from './helpers/native-bridge.js' -const nativeDistDir = fileURLToPath(new URL('../dist/', import.meta.url)) +const nativeDistDir = fileURLToPath(new URL('../../dist/', import.meta.url)) type NativeMethod = keyof NonNullable type PanelToggled = Extract type Rect = NonNullable -let browser: Browser let kit: CoreKit -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootCoreKit({id: 'fake-native-region', text: 'Hello from conciv', nativePageDir: nativeDistDir}) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await kit.cleanup() }) @@ -31,8 +26,7 @@ function isToggle(message: PageToNativeMessage): message is PanelToggled { return message.type === 'host.panelToggled' } -async function openMascotNative(): Promise { - const page = await browser.newPage({viewport: {width: 1280, height: 900}}) +async function openMascotNative(page: Page): Promise { const mascot: Mascot = {page, toggles: [], notify: () => {}} const bridge = await captureNativePosts(page) bridge.notify = (message) => { @@ -57,12 +51,14 @@ const closedRects = (all: PanelToggled[]): Rect[] => async function openFromHost(page: Page): Promise { await callNative(page, 'open', {v: 1, seq: 1}) - await expectLocator(composerBox(page)).toBeVisible({timeout: 30_000}) + await expect(composerBox(page)).toBeVisible({timeout: 30_000}) } -describe('native live region reporting', () => { - it('reports the mascot rect on a fresh closed mount so tap-to-open stays live', async () => { - const mascot = await openMascotNative() +test.describe('native live region reporting', () => { + test.use({viewport: {width: 1280, height: 900}}) + + test('reports the mascot rect on a fresh closed mount so tap-to-open stays live', async ({page: fixturePage}) => { + const mascot = await openMascotNative(fixturePage) const measured = Promise.withResolvers() mascot.notify = (toggle) => { if (toggle.open === false && toggle.mascotRect) measured.resolve(toggle) @@ -71,11 +67,12 @@ describe('native live region reporting', () => { const closed = await measured.promise expect(closed.mascotRect?.width).toBeGreaterThan(0) expect(closed.mascotRect?.height).toBeGreaterThan(0) - await mascot.page.close() }) - it('re-reports the mascot rect when the launcher moves and settles on a stable value', async () => { - const mascot = await openMascotNative() + test('re-reports the mascot rect when the launcher moves and settles on a stable value', async ({ + page: fixturePage, + }) => { + const mascot = await openMascotNative(fixturePage) const placed = Promise.withResolvers() mascot.notify = (toggle) => { if (toggle.open === false && toggle.mascotRect) placed.resolve(toggle) @@ -103,11 +100,10 @@ describe('native live region reporting', () => { expect(second).toEqual(first) expect(second?.width).toBeGreaterThan(0) expect(second?.height).toBeGreaterThan(0) - await mascot.page.close() }) - it('never reports closed mascot-region state once the panel is open', async () => { - const mascot = await openMascotNative() + test('never reports closed mascot-region state once the panel is open', async ({page: fixturePage}) => { + const mascot = await openMascotNative(fixturePage) const opened = Promise.withResolvers() mascot.notify = (toggle) => { if (toggle.open === true) opened.resolve(toggle) @@ -132,11 +128,12 @@ describe('native live region reporting', () => { const fresh = mascot.toggles.slice(settled) expect(fresh.some((toggle) => toggle.open === false)).toBe(false) expect(fresh.some((toggle) => Boolean(toggle.mascotRect))).toBe(false) - await mascot.page.close() }) - it('reports open, not a closed mascot rect, once a session is opened from the host', async () => { - const mascot = await openMascotNative() + test('reports open, not a closed mascot rect, once a session is opened from the host', async ({ + page: fixturePage, + }) => { + const mascot = await openMascotNative(fixturePage) const opened = Promise.withResolvers() mascot.notify = (toggle) => { if (toggle.open === true) opened.resolve(toggle) @@ -146,6 +143,5 @@ describe('native live region reporting', () => { expect(latest.open).toBe(true) expect(latest.mascotRect ?? null).toBeNull() expect(mascot.toggles.at(-1)).toEqual(latest) - await mascot.page.close() }) }) diff --git a/packages/embed/test/native-widget.it.test.ts b/packages/embed/tests/e2e/native-widget.it.test.ts similarity index 73% rename from packages/embed/test/native-widget.it.test.ts rename to packages/embed/tests/e2e/native-widget.it.test.ts index 59bbd2c63..3a2d74728 100644 --- a/packages/embed/test/native-widget.it.test.ts +++ b/packages/embed/tests/e2e/native-widget.it.test.ts @@ -1,7 +1,5 @@ import {fileURLToPath} from 'node:url' -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit' import {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' import type {PageToNativeMessage} from '@conciv/extension-ios/bridge' @@ -9,7 +7,7 @@ import {captureNativePosts, installNativeStub, type NativeBridge} from './helper type NativeMethod = keyof NonNullable -const nativeDistDir = fileURLToPath(new URL('../dist/', import.meta.url)) +const nativeDistDir = fileURLToPath(new URL('../../dist/', import.meta.url)) const IMAGE_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAEElEQVR4nGP4z8AARwzEcQCukw/x0F8jngAAAABJRU5ErkJggg==' @@ -36,16 +34,13 @@ const NEUTRAL_GRAB = { }, } -let browser: Browser let kit: CoreKit -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootCoreKit({id: 'fake-native', text: 'Hello from conciv', nativePageDir: nativeDistDir}) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await kit.cleanup() }) @@ -57,8 +52,7 @@ type Native = { onRebind: () => void } -async function openNative(): Promise { - const page = await browser.newPage() +async function openNative(page: Page): Promise { const observer = observeRpc(page) const bridge = await captureNativePosts(page) const native: Native = {page, bridge, observer, rebinds: [], onRebind: () => {}} @@ -93,9 +87,11 @@ const grabButton = (page: Page) => page.getByRole('button', {name: 'Select an el const panel = (page: Page) => page.getByRole('dialog', {name: 'conciv chat agent'}) const grabPreview = (page: Page) => panel(page).locator('img') -describe('native widget bridge', () => { - it('installs the native bridge, re-posts readiness, and settles after the first acked call and handshake', async () => { - const {page, bridge} = await openNative() +test.describe('native widget bridge', () => { + test('installs the native bridge, re-posts readiness, and settles after the first acked call and handshake', async ({ + page: fixturePage, + }) => { + const {page, bridge} = await openNative(fixturePage) const reposted = Promise.withResolvers() bridge.notify = () => { if (countType(bridge.posted, 'bridge.ready') > 1 && countType(bridge.posted, 'handshake.hello') > 1) { @@ -113,22 +109,23 @@ describe('native widget bridge', () => { const later = bridge.posted expect(countType(later, 'bridge.ready')).toBe(countType(settled, 'bridge.ready')) expect(countType(later, 'handshake.hello')).toBe(countType(settled, 'handshake.hello')) - await page.close() }) - it('opens the panel on native open and is idempotent, and closes on native close', async () => { - const {page} = await openNative() + test('opens the panel on native open and is idempotent, and closes on native close', async ({page: fixturePage}) => { + const {page} = await openNative(fixturePage) await callNative(page, 'open', {v: 1, seq: 1}) await callNative(page, 'open', {v: 1, seq: 2}) - await expectLocator(composerBox(page)).toHaveCount(1, {timeout: 30_000}) - await expectLocator(composerBox(page)).toBeVisible() + await expect(composerBox(page)).toHaveCount(1, {timeout: 30_000}) + await expect(composerBox(page)).toBeVisible() await callNative(page, 'close', {v: 1, seq: 3}) - await expectLocator(composerBox(page)).toBeHidden({timeout: 30_000}) - await page.close() + await expect(composerBox(page)).toBeHidden({timeout: 30_000}) }) - it('drives the native grab provider: pick posts a requestId and a matching image grabResult stages the preview', async () => { - const {page, bridge, observer} = await openNative() + test('drives the native grab provider: pick posts a requestId and a matching image grabResult stages the preview', async ({ + page: fixturePage, + }) => { + test.setTimeout(120_000) + const {page, bridge, observer} = await openNative(fixturePage) const stagedForModel = observer.completed({ path: ['drafts', 'set'], input: /\[view\][\s\S]*PaymentCardCell/, @@ -139,7 +136,7 @@ describe('native widget bridge', () => { if (message.type === 'grab.pick') picked.resolve(message) } await callNative(page, 'open', {v: 1, seq: 1}) - await expectLocator(composerBox(page)).toBeVisible({timeout: 30_000}) + await expect(composerBox(page)).toBeVisible({timeout: 30_000}) await callNative(page, 'grabCapability', {v: 1, seq: 2, grabbable: true}) await grabButton(page).click() @@ -149,20 +146,19 @@ describe('native widget bridge', () => { expect(countType(bridge.posted, 'grab.pick')).toBe(1) await callNative(page, 'grabResult', {v: 1, seq: 3, requestId: pick?.requestId, grab: NEUTRAL_GRAB}) - await expectLocator(panel(page).getByText('PaymentCardCell')).toBeVisible({timeout: 30_000}) - await expectLocator(grabPreview(page)).toHaveAttribute('src', IMAGE_DATA_URL) + await expect(panel(page).getByText('PaymentCardCell')).toBeVisible({timeout: 30_000}) + await expect(grabPreview(page)).toHaveAttribute('src', IMAGE_DATA_URL) expect(JSON.stringify((await stagedForModel).input)).toContain('PaymentCardCell') - await page.close() }) - it('ignores a grabResult whose requestId does not match the pending pick', async () => { - const {page, bridge} = await openNative() + test('ignores a grabResult whose requestId does not match the pending pick', async ({page: fixturePage}) => { + const {page, bridge} = await openNative(fixturePage) const picked = Promise.withResolvers() bridge.notify = (message) => { if (message.type === 'grab.pick') picked.resolve(message) } await callNative(page, 'open', {v: 1, seq: 1}) - await expectLocator(composerBox(page)).toBeVisible({timeout: 30_000}) + await expect(composerBox(page)).toBeVisible({timeout: 30_000}) await callNative(page, 'grabCapability', {v: 1, seq: 2, grabbable: true}) await grabButton(page).click() await picked.promise @@ -172,22 +168,19 @@ describe('native widget bridge', () => { await new Promise((resolve) => setTimeout(resolve, 500)) expect(await panel(page).getByText('PaymentCardCell').count()).toBe(0) expect(await grabPreview(page).count()).toBe(0) - await page.close() }) - it('surfaces a visible error when native reports an incompatible bridge version', async () => { - const {page} = await openNative() + test('surfaces a visible error when native reports an incompatible bridge version', async ({page: fixturePage}) => { + const {page} = await openNative(fixturePage) await callNative(page, 'bridgeIncompatible', {v: 1, seq: 1, nativeMinV: 2, nativeMaxV: 3}) - await expectLocator(page.getByText('Update the conciv widget', {exact: false})).toBeVisible({timeout: 30_000}) - await page.close() + await expect(page.getByText('Update the conciv widget', {exact: false})).toBeVisible({timeout: 30_000}) }) - it('dispatches conciv:rebind when a handshake reports a different same-core base', async () => { - const native = await openNative() + test('dispatches conciv:rebind when a handshake reports a different same-core base', async ({page: fixturePage}) => { + const native = await openNative(fixturePage) const rebound = Promise.withResolvers<{apiBase?: string}[]>() native.onRebind = () => rebound.resolve(native.rebinds) await callNative(native.page, 'handshake', {v: 1, seq: 1, apiBase: 'http://127.0.0.1:1/moved', token: null}) expect(await rebound.promise).toEqual([{apiBase: 'http://127.0.0.1:1/moved'}]) - await native.page.close() }) }) diff --git a/packages/embed/test/navigation-hold.it.test.ts b/packages/embed/tests/e2e/navigation-hold.it.test.ts similarity index 77% rename from packages/embed/test/navigation-hold.it.test.ts rename to packages/embed/tests/e2e/navigation-hold.it.test.ts index db44992cc..415dcd4aa 100644 --- a/packages/embed/test/navigation-hold.it.test.ts +++ b/packages/embed/tests/e2e/navigation-hold.it.test.ts @@ -1,5 +1,4 @@ -import {afterAll, describe, expect, it} from 'vitest' -import type {Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {setupWsProbeSuite} from './helpers/probe-suite.js' import {startProbeServer, type ProbeServer} from './helpers/probe-server.js' import {currentHref, holdFirstNavigationWrite, navigationStamp, type HeldNavigationWrite} from './helpers/navigation.js' @@ -8,7 +7,7 @@ const suite = setupWsProbeSuite() const servers: ProbeServer[] = [] -afterAll(async () => { +test.afterAll(async () => { for (const server of servers.splice(0)) await server.close() }) @@ -16,17 +15,17 @@ function navigationInput(href: string): Record { return {entries: [{href}], index: 0, updatedAt: navigationStamp()} } -async function bootHeldNavigationPage(server: ProbeServer): Promise<{page: Page; held: HeldNavigationWrite}> { - const page = await suite.browser().newPage() +async function bootHeldNavigationPage(page: Page, server: ProbeServer): Promise { const held = await holdFirstNavigationWrite(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await page.evaluate((wsUrl) => window.__CONCIV_WS_PROBE__.connect(wsUrl), server.wsUrl) - return {page, held} + return held } -describe('holdFirstNavigationWrite holds one websocket frame without stalling the socket', () => { - it('retains the first navigation write, forwards a later call, and releases on its own response', async () => { - const page = await suite.browser().newPage() +test.describe('holdFirstNavigationWrite holds one websocket frame without stalling the socket', () => { + test('retains the first navigation write, forwards a later call, and releases on its own response', async ({ + page, + }) => { const held = await holdFirstNavigationWrite(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await page.evaluate((wsUrl) => window.__CONCIV_WS_PROBE__.connect(wsUrl), suite.socketUrl()) @@ -52,13 +51,12 @@ describe('holdFirstNavigationWrite holds one websocket frame without stalling th await held.release() await write expect(await currentHref(suite.kit())).toBe('/held-by-the-frame-tap') - await page.close() }) - it('queues a second navigation write behind the retained one and flushes both in order', async () => { + test('queues a second navigation write behind the retained one and flushes both in order', async ({page}) => { const server = await startProbeServer() servers.push(server) - const {page, held} = await bootHeldNavigationPage(server) + const held = await bootHeldNavigationPage(page, server) const first = page.evaluate( (payload) => window.__CONCIV_WS_PROBE__.call(['navigation', 'set'], payload), @@ -77,13 +75,12 @@ describe('holdFirstNavigationWrite holds one websocket frame without stalling th expect(await first).toEqual({ok: true, applied: true}) expect(await second).toEqual({ok: true, applied: true}) expect(server.navigationWrites()).toEqual(['/first-write', '/second-write']) - await page.close() }) - it('releases only after the retained write is answered, not when the gate opens', async () => { + test('releases only after the retained write is answered, not when the gate opens', async ({page}) => { const server = await startProbeServer() servers.push(server) - const {page, held} = await bootHeldNavigationPage(server) + const held = await bootHeldNavigationPage(page, server) const write = page.evaluate( (payload) => window.__CONCIV_WS_PROBE__.call(['navigation', 'set'], payload), @@ -103,13 +100,13 @@ describe('holdFirstNavigationWrite holds one websocket frame without stalling th await releasing expect(settlement.done).toBe(true) expect(await write).toEqual({ok: true, applied: true}) - await page.close() }) - it('forwards a navigation write that arrives after release instead of stranding it', async () => { + test('forwards a navigation write that arrives after release instead of stranding it', async ({page}) => { + test.setTimeout(8_000) const server = await startProbeServer() servers.push(server) - const {page, held} = await bootHeldNavigationPage(server) + const held = await bootHeldNavigationPage(page, server) const first = page.evaluate( (payload) => window.__CONCIV_WS_PROBE__.call(['navigation', 'set'], payload), @@ -126,6 +123,5 @@ describe('holdFirstNavigationWrite holds one websocket frame without stalling th ) await expect(late).resolves.toEqual({ok: true, applied: true}) expect(server.navigationWrites()).toEqual(['/first-write', '/after-release']) - await page.close() - }, 8_000) + }) }) diff --git a/packages/embed/test/page-dispatch-boot.it.test.ts b/packages/embed/tests/e2e/page-dispatch-boot.it.test.ts similarity index 66% rename from packages/embed/test/page-dispatch-boot.it.test.ts rename to packages/embed/tests/e2e/page-dispatch-boot.it.test.ts index d79b915f8..ec1972bda 100644 --- a/packages/embed/test/page-dispatch-boot.it.test.ts +++ b/packages/embed/tests/e2e/page-dispatch-boot.it.test.ts @@ -1,10 +1,8 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {z} from 'zod' import {completeConnectHandshake} from '@conciv/extension-testkit/connect-handshake' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {handleHostPage, serveHost} from './helpers/host.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage, serveHost} from '../helpers/host.js' const HOST_BODY = `
page-bus-ok
@@ -17,18 +15,15 @@ const SnapshotSchema = z.object({ nodes: z.array(z.looseObject({ref: z.string(), role: z.string(), name: z.string().optional()})), }) -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit() host = await serveHost(() => handleHostPage(HOST_BODY)) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) @@ -40,10 +35,10 @@ async function buttonRef(): Promise { return node.ref } -describe('the page-tool dispatcher serves registry page tools under bootConnect', () => { +test.describe('the page-tool dispatcher serves registry page tools under bootConnect', () => { let page: Page - beforeAll(async () => { + test.beforeAll(async ({browser}) => { page = await browser.newPage() await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await page.evaluate(() => { @@ -53,19 +48,19 @@ describe('the page-tool dispatcher serves registry page tools under bootConnect' void window.concivTestHandle.mount(el) }) await completeConnectHandshake(page, kit.base) - }, 60_000) + }) - afterAll(async () => { + test.afterAll(async () => { await page.close() }) - it('a plain read round-trips server -> browser -> result', async () => { + test('a plain read round-trips server -> browser -> result', async () => { await expect(kit.rpc.registry.call({name: 'page.text', input: {selector: '#probe'}})).resolves.toMatchObject({ text: 'page-bus-ok', }) }) - it('a mutating tool acts on the page, journals by declared meta, and fires the browser mirror', async () => { + test('a mutating tool acts on the page, journals by declared meta, and fires the browser mirror', async () => { await expect(kit.rpc.registry.call({name: 'page.click', input: {selector: '#press-btn'}})).resolves.toMatchObject({ ok: true, }) @@ -74,52 +69,52 @@ describe('the page-tool dispatcher serves registry page tools under bootConnect' ) const changes = await kit.rpc.page.changes(undefined) expect(changes.map((entry) => entry.verb)).toContain('page.click') - await expectLocator(page.locator('[data-conciv-cursor]')).toHaveCount(1, {timeout: 10_000}) + await expect(page.locator('[data-conciv-cursor]')).toHaveCount(1, {timeout: 10_000}) }) - it('a ref-consuming tool resolves its target through the shared refs machinery', async () => { + test('a ref-consuming tool resolves its target through the shared refs machinery', async () => { const ref = await buttonRef() await expect(kit.rpc.registry.call({name: 'page.attr', input: {ref, attribute: 'id'}})).resolves.toMatchObject({ value: 'press-btn', }) }) - it('a missing target rejects with the transport invalid-args mapping, not a success shape', async () => { + test('a missing target rejects with the transport invalid-args mapping, not a success shape', async () => { await expect(kit.rpc.registry.call({name: 'page.text', input: {selector: '#not-here'}})).rejects.toMatchObject({ code: 'INVALID_ARGS', }) }) - describe('the page.effect verb drives host-registered effects', () => { - it('lists the highlight effect the widget registers', async () => { + test.describe('the page.effect verb drives host-registered effects', () => { + test('lists the highlight effect the widget registers', async () => { await expect(kit.rpc.registry.call({name: 'page.effect', input: {action: 'list'}})).resolves.toMatchObject({ effects: [{name: 'highlight', enabled: false}], }) }) - it('enable shows the highlight inspector on the page, disable reverts, toggle flips', async () => { + test('enable shows the highlight inspector on the page, disable reverts, toggle flips', async () => { await expect( kit.rpc.registry.call({name: 'page.effect', input: {action: 'enable', effect: 'highlight'}}), ).resolves.toMatchObject({effect: 'highlight', enabled: true}) - await expectLocator(page.locator('[data-conciv-capture]')).toHaveCount(1, {timeout: 10_000}) + await expect(page.locator('[data-conciv-capture]')).toHaveCount(1, {timeout: 10_000}) await expect( kit.rpc.registry.call({name: 'page.effect', input: {action: 'disable', effect: 'highlight'}}), ).resolves.toMatchObject({effect: 'highlight', enabled: false}) - await expectLocator(page.locator('[data-conciv-capture]')).toHaveCount(0, {timeout: 10_000}) + await expect(page.locator('[data-conciv-capture]')).toHaveCount(0, {timeout: 10_000}) await expect( kit.rpc.registry.call({name: 'page.effect', input: {action: 'toggle', effect: 'highlight'}}), ).resolves.toMatchObject({effect: 'highlight', enabled: true}) - await expectLocator(page.locator('[data-conciv-capture]')).toHaveCount(1, {timeout: 10_000}) + await expect(page.locator('[data-conciv-capture]')).toHaveCount(1, {timeout: 10_000}) await expect( kit.rpc.registry.call({name: 'page.effect', input: {action: 'toggle', effect: 'highlight'}}), ).resolves.toMatchObject({effect: 'highlight', enabled: false}) - await expectLocator(page.locator('[data-conciv-capture]')).toHaveCount(0, {timeout: 10_000}) + await expect(page.locator('[data-conciv-capture]')).toHaveCount(0, {timeout: 10_000}) }) - it('an unknown effect name rejects with the declared error', async () => { + test('an unknown effect name rejects with the declared error', async () => { await expect( kit.rpc.registry.call({name: 'page.effect', input: {action: 'enable', effect: 'confetti'}}), ).rejects.toMatchObject({code: 'UNKNOWN_EFFECT'}) diff --git a/packages/embed/test/page-dispatch-parity.it.test.ts b/packages/embed/tests/e2e/page-dispatch-parity.it.test.ts similarity index 75% rename from packages/embed/test/page-dispatch-parity.it.test.ts rename to packages/embed/tests/e2e/page-dispatch-parity.it.test.ts index 7f3ecebb0..ce4252640 100644 --- a/packages/embed/test/page-dispatch-parity.it.test.ts +++ b/packages/embed/tests/e2e/page-dispatch-parity.it.test.ts @@ -1,11 +1,9 @@ -import {afterAll, afterEach, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {z} from 'zod' import {completeConnectHandshake} from '@conciv/extension-testkit/connect-handshake' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {handleHostPage, hostPage, serveHost} from './helpers/host.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage, hostPage, serveHost} from '../helpers/host.js' const HOST_BODY = `
page-bus-ok
@@ -20,16 +18,6 @@ const SnapshotSchema = z.object({ const ChangesSchema = z.array(z.object({verb: z.string()}).loose()) -let browser: Browser - -beforeAll(async () => { - browser = await chromium.launch() -}, 60_000) - -afterAll(async () => { - await browser.close() -}) - type BootedPath = {kit: EmbedKit; page: Page} async function buttonRef(kit: EmbedKit): Promise { @@ -40,14 +28,14 @@ async function buttonRef(kit: EmbedKit): Promise { } function verbGroupBattery(boot: () => BootedPath): void { - it('read: page.text reports the live DOM', async () => { + test('read: page.text reports the live DOM', async () => { const {kit} = boot() await expect(kit.rpc.registry.call({name: 'page.text', input: {selector: '#probe'}})).resolves.toMatchObject({ text: 'page-bus-ok', }) }) - it('read: page.attr resolves a snapshot ref through the shared refs machinery', async () => { + test('read: page.attr resolves a snapshot ref through the shared refs machinery', async () => { const {kit} = boot() const ref = await buttonRef(kit) await expect(kit.rpc.registry.call({name: 'page.attr', input: {ref, attribute: 'id'}})).resolves.toMatchObject({ @@ -55,7 +43,7 @@ function verbGroupBattery(boot: () => BootedPath): void { }) }) - it('react: page.locate fails structurally on a page without a React tree', async () => { + test('react: page.locate fails structurally on a page without a React tree', async () => { const {kit} = boot() await expect(kit.rpc.registry.call({name: 'page.locate', input: {selector: '#title'}})).rejects.toMatchObject({ code: 'HANDLER_ERROR', @@ -63,7 +51,7 @@ function verbGroupBattery(boot: () => BootedPath): void { }) }) - it('act: page.click acts on the page, journals, and fires the browser mirror', async () => { + test('act: page.click acts on the page, journals, and fires the browser mirror', async () => { const {kit, page} = boot() await expect(kit.rpc.registry.call({name: 'page.click', input: {selector: '#press-btn'}})).resolves.toMatchObject({ ok: true, @@ -73,10 +61,10 @@ function verbGroupBattery(boot: () => BootedPath): void { ) const changes = ChangesSchema.parse(await kit.rpc.page.changes(undefined)) expect(changes.map((entry) => entry.verb)).toContain('page.click') - await expectLocator(page.locator('[data-conciv-cursor]')).toHaveCount(1, {timeout: 10_000}) + await expect(page.locator('[data-conciv-cursor]')).toHaveCount(1, {timeout: 10_000}) }) - it('edit-live: page.settext rewrites the DOM and journals by declared meta', async () => { + test('edit-live: page.settext rewrites the DOM and journals by declared meta', async () => { const {kit} = boot() await expect( kit.rpc.registry.call({name: 'page.settext', input: {selector: '#title', text: 'Rewritten title'}}), @@ -89,12 +77,12 @@ function verbGroupBattery(boot: () => BootedPath): void { }) } -describe('bootNormal: the widget embed serves every verb group through the dispatcher', () => { +test.describe('bootNormal: the widget embed serves every verb group through the dispatcher', () => { let kit: EmbedKit let host: {base: string; close: () => Promise} let page: Page - beforeAll(async () => { + test.beforeAll(async ({browser}) => { kit = await bootEmbedKit() host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}', body: HOST_BODY})) page = await browser.newPage() @@ -104,27 +92,27 @@ describe('bootNormal: the widget embed serves every verb group through the dispa await page.waitForFunction(() => '__CONCIV_PAGE_DRIVER__' in window, undefined, {timeout: 30_000}) await subscribed observer.dispose() - }, 60_000) + }) - afterAll(async () => { + test.afterAll(async () => { await page.close() await host.close() await kit.cleanup() }) - afterEach(async () => { + test.afterEach(async () => { await kit.rpc.page.clearChanges(undefined) }) verbGroupBattery(() => ({kit, page})) }) -describe('bootConnect: the connect handle serves the same verb groups through the dispatcher', () => { +test.describe('bootConnect: the connect handle serves the same verb groups through the dispatcher', () => { let kit: EmbedKit let host: {base: string; close: () => Promise} let page: Page - beforeAll(async () => { + test.beforeAll(async ({browser}) => { kit = await bootEmbedKit() host = await serveHost(() => handleHostPage(HOST_BODY)) page = await browser.newPage() @@ -136,15 +124,15 @@ describe('bootConnect: the connect handle serves the same verb groups through th void window.concivTestHandle.mount(el) }) await completeConnectHandshake(page, kit.base) - }, 60_000) + }) - afterAll(async () => { + test.afterAll(async () => { await page.close() await host.close() await kit.cleanup() }) - afterEach(async () => { + test.afterEach(async () => { await kit.rpc.page.clearChanges(undefined) }) diff --git a/packages/embed/test/page-plane.it.test.ts b/packages/embed/tests/e2e/page-plane.it.test.ts similarity index 55% rename from packages/embed/test/page-plane.it.test.ts rename to packages/embed/tests/e2e/page-plane.it.test.ts index be33ec685..eb9517b51 100644 --- a/packages/embed/test/page-plane.it.test.ts +++ b/packages/embed/tests/e2e/page-plane.it.test.ts @@ -1,15 +1,12 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit() host = await serveHost(() => hostPage({ @@ -18,16 +15,14 @@ beforeAll(async () => { body: '
page-bus-ok

Embed page

', }), ) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) -async function openHostPage(): Promise { - const page = await browser.newPage() +async function openHostPage(page: Page): Promise { const observer = observeRpc(page) const subscribed = observer.completed({path: ['page', 'queries'], timeout: 30_000}) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -37,27 +32,26 @@ async function openHostPage(): Promise { return page } -describe('startPagePlane executes registry page tools in the browser', () => { - it('round-trips page.text through rpc.page.queries to the DOM dispatcher', async () => { - const page = await openHostPage() +test.describe('startPagePlane executes registry page tools in the browser', () => { + test('round-trips page.text through rpc.page.queries to the DOM dispatcher', async ({page}) => { + await openHostPage(page) expect(await kit.rpc.registry.call({name: 'page.text', input: {selector: '#probe'}})).toMatchObject({ text: 'page-bus-ok', }) - await page.close() }) - it('a verb whose target does not exist rejects with a declared code, not a success-shaped string', async () => { - const page = await openHostPage() + test('a verb whose target does not exist rejects with a declared code, not a success-shaped string', async ({ + page, + }) => { + await openHostPage(page) await expect(kit.rpc.registry.call({name: 'page.text', input: {selector: '#not-here'}})).rejects.toMatchObject({ code: 'INVALID_ARGS', message: 'page.text: no element for selector #not-here', }) - await page.close() }) - it('the snapshot tool sees host page structure', async () => { - const page = await openHostPage() + test('the snapshot tool sees host page structure', async ({page}) => { + await openHostPage(page) expect(JSON.stringify(await kit.rpc.registry.call({name: 'page.snapshot', input: {}}))).toContain('Embed page') - await page.close() }) }) diff --git a/packages/embed/tests/e2e/panel-focus.it.test.ts b/packages/embed/tests/e2e/panel-focus.it.test.ts new file mode 100644 index 000000000..3e8272cd1 --- /dev/null +++ b/packages/embed/tests/e2e/panel-focus.it.test.ts @@ -0,0 +1,90 @@ +import {expect, test, type Locator, type Page} from '@playwright/test' +import {setupWidgetSuite} from './helpers/suite.js' +import {openPanel} from './helpers/panel.js' +import {hostPage, serveHost} from '../helpers/host.js' + +const suite = setupWidgetSuite() + +const COMPOSER_NAME = 'Message the conciv agent' + +function composer(page: Page) { + return page.getByRole('textbox', {name: COMPOSER_NAME}) +} + +async function ensurePanelClosed(page: Page): Promise { + const minimize = page.getByRole('button', {name: 'Minimize conciv chat'}) + const opener = page.getByRole('button', {name: 'Open conciv chat'}) + await expect(minimize.or(opener)).toBeVisible({timeout: 30_000}) + if (await minimize.isVisible()) await minimize.click() + await expect(opener).toBeVisible({timeout: 30_000}) +} + +type HostedPanel = {host: Awaited>; page: Page; hostButton: Locator} + +async function openPanelOverFocusedHostButton(page: Page): Promise { + const host = await serveHost(() => + hostPage({ + apiBase: suite.kit().base, + widget: '{"quickTerminal":false}', + body: '', + }), + ) + await page.goto(host.base, {waitUntil: 'domcontentloaded'}) + await ensurePanelClosed(page) + const hostButton = page.getByRole('button', {name: 'Host action'}) + await hostButton.click() + await expect(hostButton).toBeFocused() + await page.evaluate(() => window.dispatchEvent(new Event('conciv:open-panel'))) + await expect(composer(page)).toBeVisible({timeout: 30_000}) + return {host, page, hostButton} +} + +test.describe('panel open focuses the composer', () => { + test('focuses the composer input when the panel opens', async ({page}) => { + await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) + await openPanel(page) + await expect(composer(page)).toBeFocused({timeout: 10_000}) + await page.keyboard.type('typed without clicking') + await expect(composer(page)).toHaveText('typed without clicking') + }) +}) + +test.describe('panel close restores focus: host element captured at open wins, FAB is the fallback', () => { + test('closing via the FAB restores the host element that was focused before a programmatic open', async ({page}) => { + const {host, hostButton} = await openPanelOverFocusedHostButton(page) + await page.getByRole('button', {name: 'Minimize conciv chat'}).click() + await expect(hostButton).toBeFocused({timeout: 10_000}) + await host.close() + }) + + test('closing via the panel header restores the host element that was focused before the open', async ({page}) => { + const {host, hostButton} = await openPanelOverFocusedHostButton(page) + await page.getByRole('button', {name: 'Close chat'}).click() + await expect(hostButton).toBeFocused({timeout: 10_000}) + await host.close() + }) + + test('collapsing the panel by dragging its resize handle shut restores the host element', async ({page}) => { + const {host, hostButton} = await openPanelOverFocusedHostButton(page) + + const handle = page.getByRole('separator', {name: 'Resize chat height'}) + const grip = await handle.boundingBox() + if (!grip) throw new Error('the resize handle is not laid out') + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2) + await page.mouse.down() + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 600, {steps: 12}) + await page.mouse.up() + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) + await expect(hostButton).toBeFocused({timeout: 10_000}) + await host.close() + }) + + test('closing via the FAB falls back to FAB focus when no host element was captured at open time', async ({page}) => { + await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) + await ensurePanelClosed(page) + await page.getByRole('button', {name: 'Open conciv chat'}).click() + await expect(composer(page)).toBeVisible({timeout: 30_000}) + await page.getByRole('button', {name: 'Minimize conciv chat'}).click() + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeFocused({timeout: 10_000}) + }) +}) diff --git a/packages/embed/test/rebind.it.test.ts b/packages/embed/tests/e2e/rebind.it.test.ts similarity index 60% rename from packages/embed/test/rebind.it.test.ts rename to packages/embed/tests/e2e/rebind.it.test.ts index 48fe4583b..8b4489f41 100644 --- a/packages/embed/test/rebind.it.test.ts +++ b/packages/embed/tests/e2e/rebind.it.test.ts @@ -1,11 +1,9 @@ -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {handleHostPage, serveHost} from './helpers/host.js' +import {expect, test, type Page} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage, serveHost} from '../helpers/host.js' import {rpcObserverFor} from '@conciv/extension-testkit/rpc-observer' import {NAVIGATION_SET, setNavigation} from './helpers/navigation.js' -import {proxyTo, type ProxyCore} from './helpers/proxy.js' +import {proxyTo, type ProxyCore} from '../helpers/proxy.js' import {mountHandle, rebindHandle} from './helpers/handle.js' import {chatBox, openChatPanel, sendChatMessage} from './helpers/chat.js' @@ -13,23 +11,20 @@ const ASSISTANT_TEXT = 'Rebound reply' const USER_TEXT = 'first message before the drift' const SECOND_USER_TEXT = 'second message after the drift' -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT}) host = await serveHost(() => handleHostPage()) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) -beforeEach(async () => { +test.beforeEach(async () => { expect(await setNavigation(kit, [{href: '/'}])).toBe(true) }) @@ -40,9 +35,9 @@ function observedPage(page: Page): Page { async function openPanelTabs(page: Page): Promise { const opener = page.getByRole('button', {name: 'Open conciv chat'}) - await expectLocator(opener).toBeVisible({timeout: 30_000}) + await expect(opener).toBeVisible({timeout: 30_000}) await opener.click() - await expectLocator(page.getByRole('tab', {name: 'Mount probe'})).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('tab', {name: 'Mount probe'})).toBeVisible({timeout: 30_000}) } async function panelSession(): Promise { @@ -52,21 +47,24 @@ async function panelSession(): Promise { return (entry.href.split('/')[2] ?? '').split('?')[0] ?? null } -describe('handle.rebind survives same-core port drift', () => { +test.describe('handle.rebind survives same-core port drift', () => { let proxyA: ProxyCore let proxyB: ProxyCore - beforeAll(async () => { + test.beforeAll(async () => { proxyA = await proxyTo(kit.base) proxyB = await proxyTo(kit.base) }) - afterAll(async () => { + test.afterAll(async () => { await proxyB.close() }) - it('re-points rpc and SSE to the new port, keeps the panel open, the session, and delivers the next turn', async () => { - const page = observedPage(await browser.newPage()) + test('re-points rpc and SSE to the new port, keeps the panel open, the session, and delivers the next turn', async ({ + page, + }) => { + test.setTimeout(120_000) + observedPage(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -75,12 +73,12 @@ describe('handle.rebind survives same-core port drift', () => { await openChatPanel(page) const apiBaseProbe = page.getByRole('status', {name: 'host api base probe'}) - await expectLocator(apiBaseProbe).toHaveText(proxyA.base, {timeout: 30_000}) + await expect(apiBaseProbe).toHaveText(proxyA.base, {timeout: 30_000}) const navigationObserver = rpcObserverFor(page) const navigationMark = navigationObserver.mark() await sendChatMessage(page, USER_TEXT) - await expectLocator(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: 30_000}) await navigationObserver.completed({path: NAVIGATION_SET, since: navigationMark, timeout: 30_000}) const sessionBefore = await panelSession() expect(sessionBefore).not.toBeNull() @@ -89,42 +87,41 @@ describe('handle.rebind survives same-core port drift', () => { await rebindHandle(page, proxyB.base) await proxyA.close() - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) - await expectLocator(chatBox(page)).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) + await expect(chatBox(page)).toBeVisible({timeout: 30_000}) - await expectLocator(apiBaseProbe).toHaveText(proxyB.base, {timeout: 30_000}) + await expect(apiBaseProbe).toHaveText(proxyB.base, {timeout: 30_000}) - await expectLocator(page.getByText(USER_TEXT)).toHaveCount(1, {timeout: 30_000}) - await expectLocator(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: 30_000}) + await expect(page.getByText(USER_TEXT)).toHaveCount(1, {timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT)).toHaveCount(1, {timeout: 30_000}) await sendChatMessage(page, SECOND_USER_TEXT) - await expectLocator(page.getByText(ASSISTANT_TEXT)).toHaveCount(2, {timeout: 30_000}) - await expectLocator(page.getByText(SECOND_USER_TEXT)).toHaveCount(1, {timeout: 30_000}) - await expectLocator(page.getByText(USER_TEXT)).toHaveCount(1, {timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT)).toHaveCount(2, {timeout: 30_000}) + await expect(page.getByText(SECOND_USER_TEXT)).toHaveCount(1, {timeout: 30_000}) + await expect(page.getByText(USER_TEXT)).toHaveCount(1, {timeout: 30_000}) expect(proxyB.trafficCount()).toBeGreaterThan(beforeB) expect(await panelSession()).toBe(sessionBefore) expect(sessionBefore).not.toBeNull() expect(pageErrors).toEqual([]) - await page.close() }) }) -describe('handle.rebind remounts extension surfaces on the new core', () => { +test.describe('handle.rebind remounts extension surfaces on the new core', () => { let proxyC: ProxyCore let proxyD: ProxyCore - beforeAll(async () => { + test.beforeAll(async () => { proxyC = await proxyTo(kit.base) proxyD = await proxyTo(kit.base) }) - afterAll(async () => { + test.afterAll(async () => { await proxyD.close() }) - it('rebuilds the global surface and the open extension view against the new base', async () => { - const page = observedPage(await browser.newPage()) + test('rebuilds the global surface and the open extension view against the new base', async ({page}) => { + observedPage(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -134,41 +131,41 @@ describe('handle.rebind remounts extension surfaces on the new core', () => { const surfaceProbe = page.getByRole('status', {name: 'surface mount api base'}) const viewProbe = page.getByRole('status', {name: 'view mount api base'}) - await expectLocator(surfaceProbe).toHaveText(proxyC.base, {timeout: 30_000}) + await expect(surfaceProbe).toHaveText(proxyC.base, {timeout: 30_000}) const probeTab = page.getByRole('tab', {name: 'Mount probe'}) await probeTab.click() - await expectLocator(viewProbe).toHaveText(proxyC.base, {timeout: 30_000}) + await expect(viewProbe).toHaveText(proxyC.base, {timeout: 30_000}) const beforeD = proxyD.trafficCount() await rebindHandle(page, proxyD.base) await proxyC.close() - await expectLocator(surfaceProbe).toHaveText(proxyD.base, {timeout: 15_000}) - await expectLocator(viewProbe).toHaveText(proxyD.base, {timeout: 15_000}) - await expectLocator(probeTab).toHaveAttribute('aria-selected', 'true', {timeout: 15_000}) + await expect(surfaceProbe).toHaveText(proxyD.base, {timeout: 15_000}) + await expect(viewProbe).toHaveText(proxyD.base, {timeout: 15_000}) + await expect(probeTab).toHaveAttribute('aria-selected', 'true', {timeout: 15_000}) expect(proxyD.trafficCount()).toBeGreaterThan(beforeD) expect(pageErrors).toEqual([]) - await page.close() }) }) -describe('handle.rebind quiesces the old connection before tearing consumers down', () => { +test.describe('handle.rebind quiesces the old connection before tearing consumers down', () => { let proxyE: ProxyCore let proxyF: ProxyCore - beforeAll(async () => { + test.beforeAll(async () => { proxyE = await proxyTo(kit.base) proxyF = await proxyTo(kit.base) }) - afterAll(async () => { + test.afterAll(async () => { await proxyE.close() await proxyF.close() }) - it('writes nothing more to the old core once rebind is called', async () => { - const page = observedPage(await browser.newPage()) + test('writes nothing more to the old core once rebind is called', async ({page}) => { + test.setTimeout(90_000) + observedPage(page) const observer = rpcObserverFor(page) const framesSentPerSocket: number[] = [] page.on('websocket', (socket) => { @@ -185,10 +182,10 @@ describe('handle.rebind quiesces the old connection before tearing consumers dow await openChatPanel(page) const apiBaseProbe = page.getByRole('status', {name: 'host api base probe'}) - await expectLocator(apiBaseProbe).toHaveText(proxyE.base, {timeout: 30_000}) + await expect(apiBaseProbe).toHaveText(proxyE.base, {timeout: 30_000}) await rebindHandle(page, proxyF.base) - await expectLocator(apiBaseProbe).toHaveText(proxyF.base, {timeout: 30_000}) + await expect(apiBaseProbe).toHaveText(proxyF.base, {timeout: 30_000}) const settledOnOldSocket = framesSentPerSocket[0] ?? 0 const mark = observer.mark() @@ -196,23 +193,22 @@ describe('handle.rebind quiesces the old connection before tearing consumers dow await observer.completed({path: ['chat', 'send'], since: mark, timeout: 30_000}) expect(framesSentPerSocket[0]).toBe(settledOnOldSocket) - await page.close() }) }) -describe('handle.rebind to the base the widget is already on re-runs the transport probe', () => { +test.describe('handle.rebind to the base the widget is already on re-runs the transport probe', () => { let blockedCore: ProxyCore - beforeAll(async () => { + test.beforeAll(async () => { blockedCore = await proxyTo(kit.base, {blockUpgrades: true}) }) - afterAll(async () => { + test.afterAll(async () => { await blockedCore.close() }) - it('rides the websocket after the blocked upgrade path opens up again', async () => { - const page = await browser.newPage() + test('rides the websocket after the blocked upgrade path opens up again', async ({page}) => { + test.setTimeout(90_000) const observer = rpcObserverFor(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -225,11 +221,10 @@ describe('handle.rebind to the base the widget is already on re-runs the transpo blockedCore.setUpgradesBlocked(false) const mark = observer.mark() await rebindHandle(page, blockedCore.base) - await expectLocator(chatBox(page)).toBeVisible({timeout: 30_000}) + await expect(chatBox(page)).toBeVisible({timeout: 30_000}) await sendChatMessage(page, 'after the upgrade path opens') const reprobed = await observer.completed({path: ['chat', 'send'], since: mark, timeout: 30_000}) expect(reprobed.transport).toBe('websocket') - await page.close() }) }) diff --git a/packages/embed/test/recording-attachment.it.test.ts b/packages/embed/tests/e2e/recording-attachment.it.test.ts similarity index 77% rename from packages/embed/test/recording-attachment.it.test.ts rename to packages/embed/tests/e2e/recording-attachment.it.test.ts index 475a4a688..d6c64f4f7 100644 --- a/packages/embed/test/recording-attachment.it.test.ts +++ b/packages/embed/tests/e2e/recording-attachment.it.test.ts @@ -1,19 +1,15 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser} from 'playwright' +import {expect, test} from '@playwright/test' import {makeExtRpcClient} from '@conciv/extension' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' import recorderServer, {type RecorderRouter} from '@conciv/extension-recorder' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' import {openPanel} from './helpers/panel.js' -let browser: Browser let kit: EmbedKit let host: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: 'Recording received', extensions: [recorderServer]}) host = await serveHost(() => hostPage({ @@ -22,17 +18,16 @@ beforeAll(async () => { body: '', }), ) -}, 60_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await host.close() await kit.cleanup() }) -describe('recording attachment end to end in the real widget', () => { - it('composes the card chip, sends log text to the model, renders the durable transcript card', async () => { - const page = await browser.newPage() +test.describe('recording attachment end to end in the real widget', () => { + test('composes the card chip, sends log text to the model, renders the durable transcript card', async ({page}) => { + test.setTimeout(120_000) const observer = observeRpc(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -63,7 +58,7 @@ describe('recording attachment end to end in the real widget', () => { const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await input.fill('here is what I did') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText('Recording received').first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText('Recording received').first()).toBeVisible({timeout: 30_000}) const sessions = await kit.rpc.sessions.list() const chatSession = sessions[0]?.id @@ -87,7 +82,7 @@ describe('recording attachment end to end in the real widget', () => { expect(await page.getByRole('img').count()).toBe(0) await page.reload({waitUntil: 'domcontentloaded'}) - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) await page .getByRole('log') .getByText(/Screen recording · \d+ action/) @@ -100,10 +95,9 @@ describe('recording attachment end to end in the real widget', () => { await modal.waitFor({state: 'visible', timeout: 15_000}) await modal.getByRole('button', {name: 'Close'}).click() await modal.waitFor({state: 'hidden', timeout: 15_000}) - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) const composerAfterModal = page.getByRole('textbox', {name: 'Message the conciv agent'}) await composerAfterModal.fill('still alive after replay') - await expectLocator(composerAfterModal).toHaveText('still alive after replay') - await page.close() - }, 120_000) + await expect(composerAfterModal).toHaveText('still alive after replay') + }) }) diff --git a/packages/embed/test/reload-continuity.it.test.ts b/packages/embed/tests/e2e/reload-continuity.it.test.ts similarity index 60% rename from packages/embed/test/reload-continuity.it.test.ts rename to packages/embed/tests/e2e/reload-continuity.it.test.ts index 3e254034e..e7977870a 100644 --- a/packages/embed/test/reload-continuity.it.test.ts +++ b/packages/embed/tests/e2e/reload-continuity.it.test.ts @@ -1,5 +1,4 @@ -import {describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' +import {expect, test} from '@playwright/test' import {observeRpc} from '@conciv/extension-testkit/rpc-observer' import {setupWidgetSuite} from './helpers/suite.js' import {openPanel} from './helpers/panel.js' @@ -8,9 +7,9 @@ const ASSISTANT_TEXT = 'Continuity reply' const suite = setupWidgetSuite({text: ASSISTANT_TEXT}) -describe('reload continuity through the db-backed navigation row', () => { - it('restores the open panel route, the transcript, and the draft after a reload', async () => { - const page = await suite.browser().newPage() +test.describe('reload continuity through the db-backed navigation row', () => { + test('restores the open panel route, the transcript, and the draft after a reload', async ({page}) => { + test.setTimeout(120_000) const observer = observeRpc(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) @@ -18,7 +17,7 @@ describe('reload continuity through the db-backed navigation row', () => { const input = page.getByRole('textbox', {name: 'Message the conciv agent'}) await input.fill('remember me') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) await input.fill('an unsent draft survives') await input.press('End') @@ -34,13 +33,11 @@ describe('reload continuity through the db-backed navigation row', () => { await page.reload({waitUntil: 'domcontentloaded'}) - await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) - await expectLocator(page.getByRole('textbox', {name: 'Message the conciv agent'})).toHaveText( - 'an unsent draft survives', - {timeout: 30_000}, - ) + await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: 30_000}) + await expect(page.getByRole('textbox', {name: 'Message the conciv agent'})).toHaveText('an unsent draft survives', { + timeout: 30_000, + }) observer.dispose() - await page.close() }) }) diff --git a/packages/embed/test/rpc-fault.it.test.ts b/packages/embed/tests/e2e/rpc-fault.it.test.ts similarity index 79% rename from packages/embed/test/rpc-fault.it.test.ts rename to packages/embed/tests/e2e/rpc-fault.it.test.ts index 5938b6951..060bf7784 100644 --- a/packages/embed/test/rpc-fault.it.test.ts +++ b/packages/embed/tests/e2e/rpc-fault.it.test.ts @@ -1,12 +1,11 @@ -import {describe, expect, it} from 'vitest' +import {expect, test} from '@playwright/test' import {failRpcCalls} from '@conciv/extension-testkit/rpc-fault' import {setupWsProbeSuite} from './helpers/probe-suite.js' const suite = setupWsProbeSuite() -describe('rpc fault injection reaches calls that ride the websocket', () => { - it('fails only the targeted procedure and lets it recover after repair', async () => { - const page = await suite.browser().newPage() +test.describe('rpc fault injection reaches calls that ride the websocket', () => { + test('fails only the targeted procedure and lets it recover after repair', async ({page}) => { const models = await failRpcCalls(page, {path: ['meta', 'models']}) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await page.evaluate((wsUrl) => window.__CONCIV_WS_PROBE__.connect(wsUrl), suite.socketUrl()) @@ -27,6 +26,5 @@ describe('rpc fault injection reaches calls that ride the websocket', () => { models.repair() const repaired = await page.evaluate(() => window.__CONCIV_WS_PROBE__.call(['meta', 'models'], undefined)) expect(JSON.stringify(repaired)).toContain('models') - await page.close() }) }) diff --git a/packages/embed/test/rpc-observer.it.test.ts b/packages/embed/tests/e2e/rpc-observer.it.test.ts similarity index 79% rename from packages/embed/test/rpc-observer.it.test.ts rename to packages/embed/tests/e2e/rpc-observer.it.test.ts index 2f9a2f368..becc2746c 100644 --- a/packages/embed/test/rpc-observer.it.test.ts +++ b/packages/embed/tests/e2e/rpc-observer.it.test.ts @@ -1,5 +1,4 @@ -import {afterAll, describe, expect, it} from 'vitest' -import type {Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {encodeResponseMessage, MessageType} from '@orpc/standard-server-peer' import {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' import {setupWsProbeSuite} from './helpers/probe-suite.js' @@ -9,16 +8,15 @@ const suite = setupWsProbeSuite() const servers: ProbeServer[] = [] -afterAll(async () => { +test.afterAll(async () => { for (const server of servers.splice(0)) await server.close() }) type InjectingPage = {page: Page; observer: RpcObserver; inject: (frame: string) => void; server: ProbeServer} -async function openProbeServerPage(): Promise { +async function openProbeServerPage(page: Page): Promise { const server = await startProbeServer() servers.push(server) - const page = await suite.browser().newPage() const relay: {send: (frame: string) => void} = { send: () => { throw new Error('the probe socket was never routed') @@ -43,8 +41,7 @@ function callProbe(page: Page, path: string[], input: unknown): Promise return page.evaluate((call) => window.__CONCIV_WS_PROBE__.call(call.path, call.input), {path, input}) } -async function openProbePage(): Promise<{page: Page; observer: ReturnType}> { - const page = await suite.browser().newPage() +async function openProbePage(page: Page): Promise<{page: Page; observer: ReturnType}> { const observer = observeRpc(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await page.evaluate((wsUrl) => window.__CONCIV_WS_PROBE__.connect(wsUrl), suite.socketUrl()) @@ -55,9 +52,9 @@ function draft(sessionId: string, text: string): Record { return {sessionId, text, selectionStart: 0, selectionEnd: 0, grabs: []} } -describe('the shared rpc observer correlates calls on both transports', () => { - it('correlates a websocket request id with its terminal response, not with the outbound frame', async () => { - const {page, observer} = await openProbePage() +test.describe('the shared rpc observer correlates calls on both transports', () => { + test('correlates a websocket request id with its terminal response, not with the outbound frame', async ({page}) => { + const {observer} = await openProbePage(page) const answered = observer.completed({path: ['meta', 'tools'], timeout: 15_000}) const payload = await page.evaluate(() => window.__CONCIV_WS_PROBE__.call(['meta', 'tools'], undefined)) const call = await answered @@ -66,11 +63,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect(observer.socketCount()).toBe(1) expect(JSON.stringify(payload)).toContain('tools') observer.dispose() - await page.close() }) - it('matches a call by its decoded structured input, never by raw frame text', async () => { - const {page, observer} = await openProbePage() + test('matches a call by its decoded structured input, never by raw frame text', async ({page}) => { + const {observer} = await openProbePage(page) const {sessionId} = await suite.kit().rpc.sessions.create() const wanted = observer.completed({ path: ['drafts', 'set'], @@ -89,11 +85,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect(call.input).toMatchObject({text: 'the second draft'}) expect(observer.completedCount({path: ['drafts', 'set']})).toBe(2) observer.dispose() - await page.close() }) - it('stays pending while the call is in flight and settles only on the terminal response', async () => { - const {page, observer, server} = await openProbeServerPage() + test('stays pending while the call is in flight and settles only on the terminal response', async ({page}) => { + const {observer, server} = await openProbeServerPage(page) const answered = observer.completed({path: ['slow'], timeout: 15_000}) const settlement = {done: false} void answered.then(() => { @@ -112,11 +107,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect((await answered).status).toBe(200) expect(await inFlight).toEqual({tag: 'held'}) observer.dispose() - await page.close() }) - it('ignores an inbound response whose request id belongs to no observed call', async () => { - const {page, observer, inject, server} = await openProbeServerPage() + test('ignores an inbound response whose request id belongs to no observed call', async ({page}) => { + const {observer, inject, server} = await openProbeServerPage(page) const answered = observer.completed({path: ['slow'], timeout: 15_000}) const settlement = {done: false} void answered.then(() => { @@ -140,11 +134,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect((await answered).status).toBe(200) expect(await inFlight).toEqual({tag: 'correlated'}) observer.dispose() - await page.close() }) - it('counts an in-flight call started after the mark and skips one that only completed after it', async () => { - const {page, observer, server} = await openProbeServerPage() + test('counts an in-flight call started after the mark and skips one that only completed after it', async ({page}) => { + const {observer, server} = await openProbeServerPage(page) const early = callProbe(page, ['slow'], {tag: 'early'}) await server.slowEntered(0) await callProbe(page, ['fast'], undefined) @@ -168,11 +161,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect(observer.startedCount({path: ['slow'], since: mark})).toBe(1) expect(observer.completedCount({path: ['slow'], since: mark})).toBe(2) observer.dispose() - await page.close() }) - it('rejects a first-event wait for a call already answered over fetch instead of hanging', async () => { - const {page, observer} = await openProbePage() + test('rejects a first-event wait for a call already answered over fetch instead of hanging', async ({page}) => { + const {observer} = await openProbePage(page) await page.evaluate( (base) => fetch(`${base}/rpc/meta/tools`, { @@ -189,11 +181,10 @@ describe('the shared rpc observer correlates calls on both transports', () => { .catch((error: unknown) => (error instanceof Error ? error.message : String(error))) expect(failure).toContain('fetch transport') observer.dispose() - await page.close() }) - it('never matches an extension procedure against the core procedure of the same name', async () => { - const {page, observer} = await openProbePage() + test('never matches an extension procedure against the core procedure of the same name', async ({page}) => { + const {observer} = await openProbePage(page) const core = observer.completed({path: ['page', 'queries'], timeout: 15_000}) const settlement = {done: false} void core.then(() => { @@ -206,11 +197,12 @@ describe('the shared rpc observer correlates calls on both transports', () => { expect(observer.startedCount({path: ['page', 'queries']})).toBe(0) expect(settlement.done).toBe(false) observer.dispose() - await page.close() }) - it('reports a subscription as completed when its stream opens and surfaces the first iterator payload', async () => { - const {page, observer} = await openProbePage() + test('reports a subscription as completed when its stream opens and surfaces the first iterator payload', async ({ + page, + }) => { + const {observer} = await openProbePage(page) const opened = observer.completed({path: ['page', 'queries'], timeout: 15_000}) const firstQuery = observer.firstEvent({path: ['page', 'queries'], timeout: 15_000}) await page.evaluate(() => window.__CONCIV_WS_PROBE__.subscribe(['page', 'queries'], undefined)) @@ -223,6 +215,5 @@ describe('the shared rpc observer correlates calls on both transports', () => { const event = await firstQuery expect(JSON.stringify(event.data)).toContain('page.text') observer.dispose() - await page.close() }) }) diff --git a/packages/embed/test/transport-selection.it.test.ts b/packages/embed/tests/e2e/transport-selection.it.test.ts similarity index 65% rename from packages/embed/test/transport-selection.it.test.ts rename to packages/embed/tests/e2e/transport-selection.it.test.ts index aec767316..4ff847805 100644 --- a/packages/embed/test/transport-selection.it.test.ts +++ b/packages/embed/tests/e2e/transport-selection.it.test.ts @@ -1,16 +1,13 @@ -import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' -import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import {expect, test, type Page} from '@playwright/test' import {observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {hostPage, serveHost} from './helpers/host.js' -import {proxyTo, type ProxyCore} from './helpers/proxy.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {hostPage, serveHost} from '../helpers/host.js' +import {proxyTo, type ProxyCore} from '../helpers/proxy.js' import {setNavigation} from './helpers/navigation.js' const ASSISTANT_TEXT = 'Hello from conciv' const MOUNT_TIMEOUT_MS = 30_000 -let browser: Browser let kit: EmbedKit let openCore: ProxyCore let blockedCore: ProxyCore @@ -18,8 +15,7 @@ let openHost: {base: string; close: () => Promise} let blockedHost: {base: string; close: () => Promise} let pinnedFetchHost: {base: string; close: () => Promise} -beforeAll(async () => { - browser = await chromium.launch() +test.beforeAll(async () => { kit = await bootEmbedKit({text: ASSISTANT_TEXT}) openCore = await proxyTo(kit.base) blockedCore = await proxyTo(kit.base, {blockUpgrades: true}) @@ -28,10 +24,9 @@ beforeAll(async () => { pinnedFetchHost = await serveHost(() => hostPage({apiBase: openCore.base, widget: '{"quickTerminal":false,"transport":"fetch"}'}), ) -}, 90_000) +}) -afterAll(async () => { - await browser.close() +test.afterAll(async () => { await openHost.close() await blockedHost.close() await pinnedFetchHost.close() @@ -40,14 +35,13 @@ afterAll(async () => { await kit.cleanup() }) -beforeEach(async () => { +test.beforeEach(async () => { expect(await setNavigation(kit, [{href: '/'}])).toBe(true) }) type Tab = {page: Page; observer: RpcObserver; httpRpcUrls: string[]} -async function openTab(hostBase: string): Promise { - const page = await browser.newPage() +async function openTab(page: Page, hostBase: string): Promise { const httpRpcUrls: string[] = [] page.on('request', (request) => { const pathname = new URL(request.url()).pathname @@ -59,28 +53,27 @@ async function openTab(hostBase: string): Promise { } async function completeTurn(page: Page): Promise { - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) + await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) await page.getByRole('button', {name: 'Open conciv chat'}).click() await page.getByRole('textbox', {name: 'Message the conciv agent'}).fill('hi there') await page.getByRole('button', {name: 'Send message'}).click() - await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) + await expect(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) } -describe('the browser factory picks one transport per tab at boot', () => { - it('rides the websocket when the boot probe succeeds', async () => { - const tab = await openTab(openHost.base) +test.describe('the browser factory picks one transport per tab at boot', () => { + test('rides the websocket when the boot probe succeeds', async ({page}) => { + const tab = await openTab(page, openHost.base) try { await completeTurn(tab.page) expect(tab.observer.socketCount()).toBe(1) expect(tab.httpRpcUrls).toEqual([]) } finally { tab.observer.dispose() - await tab.page.close() } }) - it('falls back to fetch for the whole tab when the websocket upgrade is blocked', async () => { - const tab = await openTab(blockedHost.base) + test('falls back to fetch for the whole tab when the websocket upgrade is blocked', async ({page}) => { + const tab = await openTab(page, blockedHost.base) try { await completeTurn(tab.page) const call = await tab.observer.completed({path: ['chat', 'send'], timeout: MOUNT_TIMEOUT_MS}) @@ -88,12 +81,11 @@ describe('the browser factory picks one transport per tab at boot', () => { expect(tab.httpRpcUrls.length).toBeGreaterThan(0) } finally { tab.observer.dispose() - await tab.page.close() } }) - it('honours the fetch escape hatch without probing the websocket', async () => { - const tab = await openTab(pinnedFetchHost.base) + test('honours the fetch escape hatch without probing the websocket', async ({page}) => { + const tab = await openTab(page, pinnedFetchHost.base) try { await completeTurn(tab.page) expect(tab.observer.socketCount()).toBe(0) @@ -101,7 +93,6 @@ describe('the browser factory picks one transport per tab at boot', () => { expect(call.transport).toBe('fetch') } finally { tab.observer.dispose() - await tab.page.close() } }) }) diff --git a/packages/embed/test/fixtures/global-entry.ts b/packages/embed/tests/fixtures/global-entry.ts similarity index 100% rename from packages/embed/test/fixtures/global-entry.ts rename to packages/embed/tests/fixtures/global-entry.ts diff --git a/packages/embed/test/fixtures/handle-entry.tsx b/packages/embed/tests/fixtures/handle-entry.tsx similarity index 100% rename from packages/embed/test/fixtures/handle-entry.tsx rename to packages/embed/tests/fixtures/handle-entry.tsx diff --git a/packages/embed/test/fixtures/ws-probe.ts b/packages/embed/tests/fixtures/ws-probe.ts similarity index 100% rename from packages/embed/test/fixtures/ws-probe.ts rename to packages/embed/tests/fixtures/ws-probe.ts diff --git a/packages/embed/test/globals.d.ts b/packages/embed/tests/globals.d.ts similarity index 100% rename from packages/embed/test/globals.d.ts rename to packages/embed/tests/globals.d.ts diff --git a/packages/embed/test/helpers/boot.ts b/packages/embed/tests/helpers/boot.ts similarity index 100% rename from packages/embed/test/helpers/boot.ts rename to packages/embed/tests/helpers/boot.ts diff --git a/packages/embed/test/helpers/host.ts b/packages/embed/tests/helpers/host.ts similarity index 100% rename from packages/embed/test/helpers/host.ts rename to packages/embed/tests/helpers/host.ts diff --git a/packages/embed/test/helpers/proxy.ts b/packages/embed/tests/helpers/proxy.ts similarity index 100% rename from packages/embed/test/helpers/proxy.ts rename to packages/embed/tests/helpers/proxy.ts diff --git a/packages/embed/test/mount-externals.test.ts b/packages/embed/tests/unit/mount-externals.test.ts similarity index 96% rename from packages/embed/test/mount-externals.test.ts rename to packages/embed/tests/unit/mount-externals.test.ts index 43387b99c..96c351bbd 100644 --- a/packages/embed/test/mount-externals.test.ts +++ b/packages/embed/tests/unit/mount-externals.test.ts @@ -2,7 +2,7 @@ import {readdirSync, readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {describe, expect, it} from 'vitest' -const distDir = fileURLToPath(new URL('../dist/', import.meta.url)) +const distDir = fileURLToPath(new URL('../../dist/', import.meta.url)) const chunkName = readdirSync(distDir).find((name) => /^mount-impl.*\.js$/.test(name)) ?? '' const mount = chunkName ? readFileSync(distDir + chunkName, 'utf8') : '' const entry = readFileSync(distDir + 'mount.js', 'utf8') diff --git a/packages/embed/test/mount-node.test.ts b/packages/embed/tests/unit/mount-node.test.ts similarity index 84% rename from packages/embed/test/mount-node.test.ts rename to packages/embed/tests/unit/mount-node.test.ts index ae907bb2a..8ca960f9b 100644 --- a/packages/embed/test/mount-node.test.ts +++ b/packages/embed/tests/unit/mount-node.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest' -import {createConciv, mountConciv} from '../src/mount.js' +import {createConciv, mountConciv} from '../../src/mount.js' describe('createConciv outside a browser', () => { it('unmount before mount is a no-op', () => { diff --git a/packages/embed/test/native-bundle.test.ts b/packages/embed/tests/unit/native-bundle.test.ts similarity index 95% rename from packages/embed/test/native-bundle.test.ts rename to packages/embed/tests/unit/native-bundle.test.ts index 31e533540..d1764d9ff 100644 --- a/packages/embed/test/native-bundle.test.ts +++ b/packages/embed/tests/unit/native-bundle.test.ts @@ -2,7 +2,7 @@ import {readFileSync} from 'node:fs' import {fileURLToPath} from 'node:url' import {describe, expect, it} from 'vitest' -const distDir = fileURLToPath(new URL('../dist/', import.meta.url)) +const distDir = fileURLToPath(new URL('../../dist/', import.meta.url)) const nativeBundle = readFileSync(distDir + 'conciv-widget-native.global.js', 'utf8') const widgetBundle = readFileSync(distDir + 'conciv-widget.global.js', 'utf8') diff --git a/packages/embed/test/navigation-storage.test.ts b/packages/embed/tests/unit/navigation-storage.test.ts similarity index 96% rename from packages/embed/test/navigation-storage.test.ts rename to packages/embed/tests/unit/navigation-storage.test.ts index 01ca72e3d..de6e146f4 100644 --- a/packages/embed/test/navigation-storage.test.ts +++ b/packages/embed/tests/unit/navigation-storage.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest' import type {NavigationWrite} from '@conciv/protocol/chat-types' -import {makeNavigationStorage} from '../src/navigation-storage.js' +import {makeNavigationStorage} from '../../src/navigation-storage.js' type FakeNavigationRpc = { navigation: { diff --git a/packages/embed/test/proxy-upgrade.it.test.ts b/packages/embed/tests/unit/proxy-upgrade.test.ts similarity index 94% rename from packages/embed/test/proxy-upgrade.it.test.ts rename to packages/embed/tests/unit/proxy-upgrade.test.ts index 711d2be64..12421c33c 100644 --- a/packages/embed/test/proxy-upgrade.it.test.ts +++ b/packages/embed/tests/unit/proxy-upgrade.test.ts @@ -2,8 +2,8 @@ import {afterAll, beforeAll, describe, expect, it} from 'vitest' import {createORPCClient} from '@orpc/client' import {RPCLink} from '@orpc/client/websocket' import type {RpcClient} from '@conciv/contract' -import {bootEmbedKit, type EmbedKit} from './helpers/boot.js' -import {proxyTo, type ProxyCore} from './helpers/proxy.js' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {proxyTo, type ProxyCore} from '../helpers/proxy.js' let kit: EmbedKit diff --git a/packages/embed/tsconfig.json b/packages/embed/tsconfig.json index 2249434d9..099a01a80 100644 --- a/packages/embed/tsconfig.json +++ b/packages/embed/tsconfig.json @@ -15,10 +15,11 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "test/**/*.ts", - "test/**/*.tsx", + "tests/**/*.ts", + "tests/**/*.tsx", "vite.config.ts", "vite.global.config.ts", - "vitest.config.ts" + "vitest.config.ts", + "playwright.config.ts" ] } diff --git a/packages/embed/vite.global.config.ts b/packages/embed/vite.global.config.ts index e582c8af9..5ab09607c 100644 --- a/packages/embed/vite.global.config.ts +++ b/packages/embed/vite.global.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ define: {'define.amd': 'false', 'process.env.NODE_ENV': '"production"'}, build: { lib: { - entry: fileURLToPath(new URL('test/fixtures/global-entry.ts', import.meta.url)), + entry: fileURLToPath(new URL('tests/fixtures/global-entry.ts', import.meta.url)), formats: ['iife'], name: 'ConcivWidget', fileName: () => 'conciv-widget.global.js', diff --git a/packages/embed/vite.handle.config.ts b/packages/embed/vite.handle.config.ts index ad5e65d2a..e459a5fb7 100644 --- a/packages/embed/vite.handle.config.ts +++ b/packages/embed/vite.handle.config.ts @@ -7,12 +7,12 @@ export default defineConfig({ define: {'define.amd': 'false', 'process.env.NODE_ENV': '"production"'}, build: { lib: { - entry: fileURLToPath(new URL('test/fixtures/handle-entry.tsx', import.meta.url)), + entry: fileURLToPath(new URL('tests/fixtures/handle-entry.tsx', import.meta.url)), formats: ['iife'], name: 'ConcivHandle', fileName: () => 'conciv-handle.global.js', }, - outDir: 'test/dist', + outDir: 'tests/dist', cssCodeSplit: false, emptyOutDir: true, sourcemap: false, diff --git a/packages/embed/vite.ws-probe.config.ts b/packages/embed/vite.ws-probe.config.ts index 2797002eb..f7c0c18e0 100644 --- a/packages/embed/vite.ws-probe.config.ts +++ b/packages/embed/vite.ws-probe.config.ts @@ -5,12 +5,12 @@ export default defineConfig({ define: {'process.env.NODE_ENV': '"production"'}, build: { lib: { - entry: fileURLToPath(new URL('test/fixtures/ws-probe.ts', import.meta.url)), + entry: fileURLToPath(new URL('tests/fixtures/ws-probe.ts', import.meta.url)), formats: ['iife'], name: 'ConcivWsProbe', fileName: () => 'conciv-ws-probe.global.js', }, - outDir: 'test/dist', + outDir: 'tests/dist', emptyOutDir: false, sourcemap: false, }, diff --git a/packages/embed/vitest.config.ts b/packages/embed/vitest.config.ts index 551c6253a..bd683eb28 100644 --- a/packages/embed/vitest.config.ts +++ b/packages/embed/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { ...ciTest(), environment: 'node', - include: ['test/**/*.test.ts'], + include: ['tests/unit/**/*.test.ts'], fileParallelism: false, testTimeout: 60_000, hookTimeout: 90_000, diff --git a/packages/oxlint-plugin/src/banned-vocabulary.js b/packages/oxlint-plugin/src/banned-vocabulary.js index 88b846eb4..c3ce9f2ce 100644 --- a/packages/oxlint-plugin/src/banned-vocabulary.js +++ b/packages/oxlint-plugin/src/banned-vocabulary.js @@ -36,6 +36,7 @@ const NATIVE_AND_REACT_BRIDGE_PATHS = [ 'packages/publish/src/swift-mirror.ts', 'packages/page/src/', 'packages/embed/src/mount-impl.tsx', + 'packages/embed/tests/e2e/helpers/native-bridge.ts', ] const PRE_REWRITE_BRIDGE_PATHS = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a769ad18a..0d1d14dec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1773,6 +1773,9 @@ importers: '@orpc/standard-server-peer': specifier: 'catalog:' version: 1.14.7(@opentelemetry/api@1.9.1) + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@solid-primitives/event-listener': specifier: ^2.4.6 version: 2.4.6(solid-js@1.9.14) @@ -1788,9 +1791,6 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - playwright: - specifier: ^1.61.1 - version: 1.61.1 publint: specifier: ^0.3.14 version: 0.3.21 From eaec49a6a9154f7d50771bd89f12d211f4f41f6c Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:05:07 +0300 Subject: [PATCH 2/3] fix(embed): budget per-test timeouts and make e2e teardown never throw Sum the sequential wait budgets (expect timeouts, observer completed timeouts, and the shared helper waits they call into) for every packages/embed/tests/e2e/*.it.test.ts test and set test.setTimeout on any test whose sum clears the ~45s default, so slow CI runs don't trip the global 60s playwright timeout. Move teardown robustness into the shared helpers instead of wrapping every test.afterAll in try/finally at the call site: bootEmbedKit's returned cleanup, serveHost's returned close, proxyTo's returned close, and startProbeServer's returned close now catch and log rather than throw, so a failure in one step never skips the rest of a multi-step afterAll. Co-Authored-By: Claude Fable 5 --- .../tests/e2e/composer-rich-input.it.test.ts | 10 +++++++++- .../e2e/composer-trigger-menu.it.test.ts | 5 +++++ .../tests/e2e/connection-pool.it.test.ts | 1 + .../embed/tests/e2e/create-conciv.it.test.ts | 2 +- .../tests/e2e/draft-selection.it.test.ts | 1 + .../tests/e2e/element-capture.it.test.ts | 5 +++++ packages/embed/tests/e2e/embed.it.test.ts | 19 ++++++++++++++++--- .../embed/tests/e2e/forced-drop.it.test.ts | 1 + .../embed/tests/e2e/helpers/probe-server.ts | 7 +++++++ .../embed/tests/e2e/model-selector.it.test.ts | 1 + .../embed/tests/e2e/page-plane.it.test.ts | 3 +++ .../embed/tests/e2e/panel-focus.it.test.ts | 5 +++++ packages/embed/tests/e2e/rebind.it.test.ts | 7 ++++--- .../tests/e2e/recording-attachment.it.test.ts | 2 +- .../tests/e2e/reload-continuity.it.test.ts | 2 +- .../tests/e2e/transport-selection.it.test.ts | 3 +++ packages/embed/tests/helpers/boot.ts | 12 +++++++++++- packages/embed/tests/helpers/host.ts | 11 ++++++++++- packages/embed/tests/helpers/proxy.ts | 10 +++++++--- 19 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/embed/tests/e2e/composer-rich-input.it.test.ts b/packages/embed/tests/e2e/composer-rich-input.it.test.ts index e569fc818..a5e649b52 100644 --- a/packages/embed/tests/e2e/composer-rich-input.it.test.ts +++ b/packages/embed/tests/e2e/composer-rich-input.it.test.ts @@ -64,6 +64,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { test('types multiline text with slash and mention chips and submits the exact directive string', async ({ page: fixturePage, }) => { + test.setTimeout(180_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -91,6 +92,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { test('backspace removes a selected command chip in two steps, never a partial directive', async ({ page: fixturePage, }) => { + test.setTimeout(120_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -108,6 +110,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { }) test('forward delete removes the chip ahead of the caret in one step', async ({page: fixturePage}) => { + test.setTimeout(120_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -127,7 +130,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { test('Escape closes the typeahead first, cancels the run next, and closes the panel last', async ({ page: fixturePage, }) => { - test.setTimeout(120_000) + test.setTimeout(180_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -163,6 +166,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { }) test('a failed send restores the full draft into the composer', async ({page: fixturePage}) => { + test.setTimeout(90_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -177,6 +181,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { }) test('a reload restores the draft as plain directive text with the caret at the end', async ({page: fixturePage}) => { + test.setTimeout(180_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -196,6 +201,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { }) test('a reload restores the caret where the draft left it, not at the end', async ({page: fixturePage}) => { + test.setTimeout(90_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -218,6 +224,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { test('Enter during IME composition never submits; Enter after the commit sends the composed text', async ({ page: fixturePage, }) => { + test.setTimeout(90_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) @@ -240,6 +247,7 @@ test.describe('the rich composer input in the live widget shadow DOM', () => { }) test('the send button submits the visible draft including pending composition text', async ({page: fixturePage}) => { + test.setTimeout(90_000) const {page, observer} = observedPage(fixturePage) await openComposer(page) const input = composer(page) diff --git a/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts b/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts index 3528240fa..fe214bb5f 100644 --- a/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts +++ b/packages/embed/tests/e2e/composer-trigger-menu.it.test.ts @@ -47,6 +47,7 @@ async function expectActiveOption(page: Page, option: Locator): Promise { test.describe('the composer trigger menu inside the live widget panel', () => { test('groups commands by source with a visible header and a description per command', async ({page}) => { + test.setTimeout(90_000) await openComposer(page) await composer(page).pressSequentially('/co') @@ -63,6 +64,7 @@ test.describe('the composer trigger menu inside the live widget panel', () => { }) test('moves the highlight with the arrow keys and commits the highlighted command with Enter', async ({page}) => { + test.setTimeout(240_000) await openComposer(page) const input = composer(page) await input.pressSequentially('/co') @@ -82,6 +84,7 @@ test.describe('the composer trigger menu inside the live widget panel', () => { }) test('keeps the highlighted option inside the panel viewport while arrowing down a long list', async ({page}) => { + test.setTimeout(120_000) await openComposer(page) await composer(page).pressSequentially('/task-') @@ -101,6 +104,7 @@ test.describe('the composer trigger menu inside the live widget panel', () => { }) test('groups tools in the mention menu and commits the highlighted tool with Enter', async ({page}) => { + test.setTimeout(180_000) await openComposer(page) const input = composer(page) await input.pressSequentially('@recording_') @@ -121,6 +125,7 @@ test.describe('the composer trigger menu inside the live widget panel', () => { test('never hands the keyboard to the menu: Tab closes it and the next keystroke still edits the composer', async ({ page, }) => { + test.setTimeout(120_000) await openComposer(page) const input = composer(page) await input.pressSequentially('/co') diff --git a/packages/embed/tests/e2e/connection-pool.it.test.ts b/packages/embed/tests/e2e/connection-pool.it.test.ts index 73936b258..8b02e0614 100644 --- a/packages/embed/tests/e2e/connection-pool.it.test.ts +++ b/packages/embed/tests/e2e/connection-pool.it.test.ts @@ -61,6 +61,7 @@ test.describe('six widget tabs sharing one browserContext connection pool (the p test('gives every tab one rpc websocket, no rpc over http, and a working chat round trip in the last tab', async ({ context, }) => { + test.setTimeout(180_000) const tabs: Tab[] = [] try { for (let index = 0; index < SHARED_CONTEXT_TAB_COUNT; index += 1) tabs.push(await openTab(context)) diff --git a/packages/embed/tests/e2e/create-conciv.it.test.ts b/packages/embed/tests/e2e/create-conciv.it.test.ts index 687c46698..0806a7a76 100644 --- a/packages/embed/tests/e2e/create-conciv.it.test.ts +++ b/packages/embed/tests/e2e/create-conciv.it.test.ts @@ -126,7 +126,7 @@ test.describe('createConciv lifecycle', () => { }) test('closes the tab rpc websocket on unmount and dials a fresh one on remount', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(180_000) expect(await setNavigation(kit, [{href: '/'}])).toBe(true) await openPage(page) const observer = rpcObserverFor(page) diff --git a/packages/embed/tests/e2e/draft-selection.it.test.ts b/packages/embed/tests/e2e/draft-selection.it.test.ts index 40ee5aaed..670440528 100644 --- a/packages/embed/tests/e2e/draft-selection.it.test.ts +++ b/packages/embed/tests/e2e/draft-selection.it.test.ts @@ -7,6 +7,7 @@ const suite = setupWidgetSuite() test.describe('draft persistence carries the caret offsets', () => { test('persists the draft text with the caret position after the debounce', async ({page}) => { + test.setTimeout(90_000) const observer = observeRpc(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) diff --git a/packages/embed/tests/e2e/element-capture.it.test.ts b/packages/embed/tests/e2e/element-capture.it.test.ts index 2002d9324..e11037144 100644 --- a/packages/embed/tests/e2e/element-capture.it.test.ts +++ b/packages/embed/tests/e2e/element-capture.it.test.ts @@ -46,6 +46,7 @@ async function sendAndRevealThought(page: Page, message: string): Promise test.describe('a page tool run through the widget stores a frozen picture of the element it touched', () => { test('keeps the pre-edit element after the page flips its theme and deletes the node', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) await kit.callTool('page.settext', {selector: '#prose', text: 'rewritten by the agent'}, sessionId) @@ -71,6 +72,7 @@ test.describe('a page tool run through the widget stores a frozen picture of the }) test('never lets a password value reach the stored capture or the tool result', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) const result = await kit.callTool('page.fill', {selector: '#secret', value: 'typed by the agent'}, sessionId) const stored: SessionCaptures = await kit.rpc.captures.list({sessionId}) @@ -82,6 +84,7 @@ test.describe('a page tool run through the widget stores a frozen picture of the }) test('hands the harness a result with no capture in it', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) const result = await kit.callTool('page.click', {selector: '#cta'}, sessionId) expect(JSON.stringify(result)).not.toContain('cssBundleId') @@ -90,6 +93,7 @@ test.describe('a page tool run through the widget stores a frozen picture of the }) test('takes no capture for a read verb', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) const before: SessionCaptures = await kit.rpc.captures.list({sessionId}) await kit.callTool('page.text', {selector: '#cta'}, sessionId) @@ -100,6 +104,7 @@ test.describe('a page tool run through the widget stores a frozen picture of the test('renders a page verb run through real code mode with its element preview ready in the widget', async ({ page, }) => { + test.setTimeout(180_000) await openHostPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', { diff --git a/packages/embed/tests/e2e/embed.it.test.ts b/packages/embed/tests/e2e/embed.it.test.ts index d624e61d1..9d57b5896 100644 --- a/packages/embed/tests/e2e/embed.it.test.ts +++ b/packages/embed/tests/e2e/embed.it.test.ts @@ -85,6 +85,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('a widget navigation write that lands after a newer one loses, even in flight', async ({page}) => { + test.setTimeout(120_000) observedPage(page) const held = await holdFirstNavigationWrite(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -103,6 +104,7 @@ test.describe('embed boots the conciv app against a real core', () => { page, context, }) => { + test.setTimeout(240_000) const frozen = Date.now() const before = observedPage(page) await freezeClock(before, frozen) @@ -131,6 +133,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('fab close is a shutter: reopening restores the same view without touching history', async ({page}) => { + test.setTimeout(240_000) await openPage(page) await openPanel(page) await page.getByRole('tab', {name: 'Terminal'}).click() @@ -150,6 +153,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('a reload restores the panel open on the same view', async ({page, context}) => { + test.setTimeout(180_000) const first = await openPage(page) await openPanel(first) const switched = waitForNavigationWriteCarrying(first, '/terminal') @@ -165,6 +169,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('a reload after closing the panel boots shut', async ({page, context}) => { + test.setTimeout(180_000) const first = observedPage(page) const opened = waitForNavigationWrite(first) await first.goto(host.base, {waitUntil: 'domcontentloaded'}) @@ -182,6 +187,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('renders the fab instantly and opens the panel', async ({page}) => { + test.setTimeout(120_000) await openPage(page) await expect(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 30_000}) await openPanel(page) @@ -189,6 +195,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('opening and closing the panel keeps the host page where the reader scrolled it', async ({page}) => { + test.setTimeout(180_000) observedPage(page) await page.goto(longHost.base, {waitUntil: 'domcontentloaded'}) const heading = page.getByRole('heading', {name: HOST_HEADING}) @@ -209,12 +216,14 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('sends a message and renders the assistant reply from the fake harness', async ({page}) => { + test.setTimeout(120_000) await openPage(page) await openPanel(page) await sendMessage(page, 'hi there', ASSISTANT_TEXT) }) test('shows Stop instead of Send while a run is streaming; typing stays enabled', async ({page}) => { + test.setTimeout(180_000) await openPage(page) await openPanel(page) kit.harness.script.hold() @@ -229,6 +238,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('Escape closes the panel back to the fab', async ({page}) => { + test.setTimeout(120_000) await openPage(page) await openPanel(page) await page.getByRole('textbox', {name: 'Message the conciv agent'}).press('Escape') @@ -237,7 +247,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('renders the conciv_ui blocking card from the tool part and answers via uiReply', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(180_000) await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', { @@ -253,6 +263,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('renders catalog and code-mode parts without blanking the transcript', async ({page}) => { + test.setTimeout(120_000) await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('catalog', {search: 'weather'}, {blocking: false}) @@ -266,7 +277,7 @@ test.describe('embed boots the conciv app against a real core', () => { }) test('renders the new tool cards for results that do not match their payload schema', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(240_000) await openPage(page) await openPanel(page) kit.harness.script.scriptToolCall('execute_typescript', {typescriptCode: 'return 1'}, {blocking: false}) @@ -306,6 +317,7 @@ test.describe('embed at a phone viewport', () => { }) test('paints an opaque sheet so the host page never shows through', async ({page}) => { + test.setTimeout(240_000) const shootOver = async (backdrop: string): Promise => { expect(await setNavigation(phoneKit, [{href: '/'}])).toBe(true) await page.goto(`${phoneHost.base}/?backdrop=${backdrop}`, {waitUntil: 'domcontentloaded'}) @@ -320,7 +332,7 @@ test.describe('embed at a phone viewport', () => { }) test('opens as a full-screen sheet with the launcher hidden and the composer reachable', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(240_000) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(page) await expect(page.getByRole('button', {name: 'Open conciv chat'})).toHaveCount(0, {timeout: 30_000}) @@ -334,6 +346,7 @@ test.describe('embed at a phone viewport', () => { test.use({viewport: {width: 320, height: 800}}) test('keeps Stop and Send inside the sheet on a narrow phone while a run streams', async ({page}) => { + test.setTimeout(180_000) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await openPanel(page) kit.harness.script.hold() diff --git a/packages/embed/tests/e2e/forced-drop.it.test.ts b/packages/embed/tests/e2e/forced-drop.it.test.ts index ce8f3e050..34a99dc8b 100644 --- a/packages/embed/tests/e2e/forced-drop.it.test.ts +++ b/packages/embed/tests/e2e/forced-drop.it.test.ts @@ -39,6 +39,7 @@ test.describe('chat survives a forced websocket drop', () => { test('re-subscribes and completes the next turn on a fresh socket after the core drops every connection', async ({ page, }) => { + test.setTimeout(120_000) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) const observer = rpcObserverFor(page) diff --git a/packages/embed/tests/e2e/helpers/probe-server.ts b/packages/embed/tests/e2e/helpers/probe-server.ts index d9ba2565f..62d61f09b 100644 --- a/packages/embed/tests/e2e/helpers/probe-server.ts +++ b/packages/embed/tests/e2e/helpers/probe-server.ts @@ -84,6 +84,13 @@ export async function startProbeServer(): Promise { const served = await serveRpcRouter({router}) return { ...served, + close: async () => { + try { + await served.close() + } catch (error) { + console.error('[embed-testkit] probe server close failed:', error) + } + }, slowEntered: slow.entered, releaseSlow: slow.release, navigationEntered: navigation.entered, diff --git a/packages/embed/tests/e2e/model-selector.it.test.ts b/packages/embed/tests/e2e/model-selector.it.test.ts index 815fc3df2..fcee71003 100644 --- a/packages/embed/tests/e2e/model-selector.it.test.ts +++ b/packages/embed/tests/e2e/model-selector.it.test.ts @@ -14,6 +14,7 @@ test.describe('model selector error path', () => { test.use({viewport: {width: 900, height: 760}}) test('offers a retry when meta.models fails and recovers on retry', async ({page}) => { + test.setTimeout(180_000) const models = await failRpcCalls(page, {path: ['meta', 'models']}) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) diff --git a/packages/embed/tests/e2e/page-plane.it.test.ts b/packages/embed/tests/e2e/page-plane.it.test.ts index eb9517b51..4da83e7ef 100644 --- a/packages/embed/tests/e2e/page-plane.it.test.ts +++ b/packages/embed/tests/e2e/page-plane.it.test.ts @@ -34,6 +34,7 @@ async function openHostPage(page: Page): Promise { test.describe('startPagePlane executes registry page tools in the browser', () => { test('round-trips page.text through rpc.page.queries to the DOM dispatcher', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) expect(await kit.rpc.registry.call({name: 'page.text', input: {selector: '#probe'}})).toMatchObject({ text: 'page-bus-ok', @@ -43,6 +44,7 @@ test.describe('startPagePlane executes registry page tools in the browser', () = test('a verb whose target does not exist rejects with a declared code, not a success-shaped string', async ({ page, }) => { + test.setTimeout(90_000) await openHostPage(page) await expect(kit.rpc.registry.call({name: 'page.text', input: {selector: '#not-here'}})).rejects.toMatchObject({ code: 'INVALID_ARGS', @@ -51,6 +53,7 @@ test.describe('startPagePlane executes registry page tools in the browser', () = }) test('the snapshot tool sees host page structure', async ({page}) => { + test.setTimeout(90_000) await openHostPage(page) expect(JSON.stringify(await kit.rpc.registry.call({name: 'page.snapshot', input: {}}))).toContain('Embed page') }) diff --git a/packages/embed/tests/e2e/panel-focus.it.test.ts b/packages/embed/tests/e2e/panel-focus.it.test.ts index 3e8272cd1..8eaeff72d 100644 --- a/packages/embed/tests/e2e/panel-focus.it.test.ts +++ b/packages/embed/tests/e2e/panel-focus.it.test.ts @@ -41,6 +41,7 @@ async function openPanelOverFocusedHostButton(page: Page): Promise test.describe('panel open focuses the composer', () => { test('focuses the composer input when the panel opens', async ({page}) => { + test.setTimeout(90_000) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) await expect(composer(page)).toBeFocused({timeout: 10_000}) @@ -51,6 +52,7 @@ test.describe('panel open focuses the composer', () => { test.describe('panel close restores focus: host element captured at open wins, FAB is the fallback', () => { test('closing via the FAB restores the host element that was focused before a programmatic open', async ({page}) => { + test.setTimeout(120_000) const {host, hostButton} = await openPanelOverFocusedHostButton(page) await page.getByRole('button', {name: 'Minimize conciv chat'}).click() await expect(hostButton).toBeFocused({timeout: 10_000}) @@ -58,6 +60,7 @@ test.describe('panel close restores focus: host element captured at open wins, F }) test('closing via the panel header restores the host element that was focused before the open', async ({page}) => { + test.setTimeout(120_000) const {host, hostButton} = await openPanelOverFocusedHostButton(page) await page.getByRole('button', {name: 'Close chat'}).click() await expect(hostButton).toBeFocused({timeout: 10_000}) @@ -65,6 +68,7 @@ test.describe('panel close restores focus: host element captured at open wins, F }) test('collapsing the panel by dragging its resize handle shut restores the host element', async ({page}) => { + test.setTimeout(180_000) const {host, hostButton} = await openPanelOverFocusedHostButton(page) const handle = page.getByRole('separator', {name: 'Resize chat height'}) @@ -80,6 +84,7 @@ test.describe('panel close restores focus: host element captured at open wins, F }) test('closing via the FAB falls back to FAB focus when no host element was captured at open time', async ({page}) => { + test.setTimeout(120_000) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await ensurePanelClosed(page) await page.getByRole('button', {name: 'Open conciv chat'}).click() diff --git a/packages/embed/tests/e2e/rebind.it.test.ts b/packages/embed/tests/e2e/rebind.it.test.ts index 8b4489f41..4b08f93d5 100644 --- a/packages/embed/tests/e2e/rebind.it.test.ts +++ b/packages/embed/tests/e2e/rebind.it.test.ts @@ -63,7 +63,7 @@ test.describe('handle.rebind survives same-core port drift', () => { test('re-points rpc and SSE to the new port, keeps the panel open, the session, and delivers the next turn', async ({ page, }) => { - test.setTimeout(120_000) + test.setTimeout(480_000) observedPage(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) @@ -121,6 +121,7 @@ test.describe('handle.rebind remounts extension surfaces on the new core', () => }) test('rebuilds the global surface and the open extension view against the new base', async ({page}) => { + test.setTimeout(240_000) observedPage(page) const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(String(error))) @@ -164,7 +165,7 @@ test.describe('handle.rebind quiesces the old connection before tearing consumer }) test('writes nothing more to the old core once rebind is called', async ({page}) => { - test.setTimeout(90_000) + test.setTimeout(180_000) observedPage(page) const observer = rpcObserverFor(page) const framesSentPerSocket: number[] = [] @@ -208,7 +209,7 @@ test.describe('handle.rebind to the base the widget is already on re-runs the tr }) test('rides the websocket after the blocked upgrade path opens up again', async ({page}) => { - test.setTimeout(90_000) + test.setTimeout(180_000) const observer = rpcObserverFor(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) diff --git a/packages/embed/tests/e2e/recording-attachment.it.test.ts b/packages/embed/tests/e2e/recording-attachment.it.test.ts index d6c64f4f7..16fb4b3a1 100644 --- a/packages/embed/tests/e2e/recording-attachment.it.test.ts +++ b/packages/embed/tests/e2e/recording-attachment.it.test.ts @@ -27,7 +27,7 @@ test.afterAll(async () => { test.describe('recording attachment end to end in the real widget', () => { test('composes the card chip, sends log text to the model, renders the durable transcript card', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(480_000) const observer = observeRpc(page) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) diff --git a/packages/embed/tests/e2e/reload-continuity.it.test.ts b/packages/embed/tests/e2e/reload-continuity.it.test.ts index e7977870a..a5069672f 100644 --- a/packages/embed/tests/e2e/reload-continuity.it.test.ts +++ b/packages/embed/tests/e2e/reload-continuity.it.test.ts @@ -9,7 +9,7 @@ const suite = setupWidgetSuite({text: ASSISTANT_TEXT}) test.describe('reload continuity through the db-backed navigation row', () => { test('restores the open panel route, the transcript, and the draft after a reload', async ({page}) => { - test.setTimeout(120_000) + test.setTimeout(240_000) const observer = observeRpc(page) await page.goto(suite.host().base, {waitUntil: 'domcontentloaded'}) await openPanel(page) diff --git a/packages/embed/tests/e2e/transport-selection.it.test.ts b/packages/embed/tests/e2e/transport-selection.it.test.ts index 4ff847805..2dde8daee 100644 --- a/packages/embed/tests/e2e/transport-selection.it.test.ts +++ b/packages/embed/tests/e2e/transport-selection.it.test.ts @@ -62,6 +62,7 @@ async function completeTurn(page: Page): Promise { test.describe('the browser factory picks one transport per tab at boot', () => { test('rides the websocket when the boot probe succeeds', async ({page}) => { + test.setTimeout(90_000) const tab = await openTab(page, openHost.base) try { await completeTurn(tab.page) @@ -73,6 +74,7 @@ test.describe('the browser factory picks one transport per tab at boot', () => { }) test('falls back to fetch for the whole tab when the websocket upgrade is blocked', async ({page}) => { + test.setTimeout(120_000) const tab = await openTab(page, blockedHost.base) try { await completeTurn(tab.page) @@ -85,6 +87,7 @@ test.describe('the browser factory picks one transport per tab at boot', () => { }) test('honours the fetch escape hatch without probing the websocket', async ({page}) => { + test.setTimeout(120_000) const tab = await openTab(page, pinnedFetchHost.base) try { await completeTurn(tab.page) diff --git a/packages/embed/tests/helpers/boot.ts b/packages/embed/tests/helpers/boot.ts index 53e0f6adf..59c6562b4 100644 --- a/packages/embed/tests/helpers/boot.ts +++ b/packages/embed/tests/helpers/boot.ts @@ -7,11 +7,21 @@ export type EmbedKit = CoreKit export async function bootEmbedKit( opts: {text?: string; extensions?: AnyExtension[]; models?: HarnessModel[]; commands?: HarnessCommand[]} = {}, ): Promise { - return bootCoreKit({ + const kit = await bootCoreKit({ id: 'fake-embed', text: opts.text, extensions: opts.extensions, models: opts.models, commands: opts.commands, }) + return { + ...kit, + cleanup: async () => { + try { + await kit.cleanup() + } catch (error) { + console.error('[embed-testkit] kit cleanup failed:', error) + } + }, + } } diff --git a/packages/embed/tests/helpers/host.ts b/packages/embed/tests/helpers/host.ts index 7f6037231..294dafaad 100644 --- a/packages/embed/tests/helpers/host.ts +++ b/packages/embed/tests/helpers/host.ts @@ -66,5 +66,14 @@ export async function serveHost(html: (url: URL) => string): Promise<{base: stri res.end(html(new URL(req.url ?? '/', 'http://127.0.0.1'))) }) const {base, close} = await listenLocal(server) - return {base, close} + return { + base, + close: async () => { + try { + await close() + } catch (error) { + console.error('[embed-testkit] host close failed:', error) + } + }, + } } diff --git a/packages/embed/tests/helpers/proxy.ts b/packages/embed/tests/helpers/proxy.ts index e4063b68c..b04c54b09 100644 --- a/packages/embed/tests/helpers/proxy.ts +++ b/packages/embed/tests/helpers/proxy.ts @@ -93,9 +93,13 @@ export async function proxyTo(targetBase: string, opts: {blockUpgrades?: boolean upgradesBlocked = blocked }, close: async () => { - for (const socket of piped) socket.destroy() - piped.clear() - await close() + try { + for (const socket of piped) socket.destroy() + piped.clear() + await close() + } catch (error) { + console.error('[embed-testkit] proxy close failed:', error) + } }, } } From 113f701529d91046ca7ccf01648bef78a3b11d01 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:22:14 +0300 Subject: [PATCH 3/3] fix(embed): close dedicated panel-focus hosts on teardown, propagate close errors panel-focus.it.test.ts left dedicated hosts open on a failed assertion since close() only ran on the happy path; move cleanup to a module-level afterEach that owns every host openPanelOverFocusedHostButton creates. Also make listenLocal's close callback reject on a server.close error instead of silently discarding it, so serveHost.close's existing catch/log wrapper can actually log cleanup failures. Co-Authored-By: Claude Fable 5 --- packages/embed/tests/e2e/panel-focus.it.test.ts | 16 ++++++++++------ packages/embed/tests/helpers/host.ts | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/embed/tests/e2e/panel-focus.it.test.ts b/packages/embed/tests/e2e/panel-focus.it.test.ts index 8eaeff72d..d03fb5b73 100644 --- a/packages/embed/tests/e2e/panel-focus.it.test.ts +++ b/packages/embed/tests/e2e/panel-focus.it.test.ts @@ -21,6 +21,12 @@ async function ensurePanelClosed(page: Page): Promise { type HostedPanel = {host: Awaited>; page: Page; hostButton: Locator} +const dedicatedHosts: Array<{close: () => Promise}> = [] + +test.afterEach(async () => { + for (const dedicatedHost of dedicatedHosts.splice(0)) await dedicatedHost.close() +}) + async function openPanelOverFocusedHostButton(page: Page): Promise { const host = await serveHost(() => hostPage({ @@ -29,6 +35,7 @@ async function openPanelOverFocusedHostButton(page: Page): Promise body: '', }), ) + dedicatedHosts.push(host) await page.goto(host.base, {waitUntil: 'domcontentloaded'}) await ensurePanelClosed(page) const hostButton = page.getByRole('button', {name: 'Host action'}) @@ -53,23 +60,21 @@ test.describe('panel open focuses the composer', () => { test.describe('panel close restores focus: host element captured at open wins, FAB is the fallback', () => { test('closing via the FAB restores the host element that was focused before a programmatic open', async ({page}) => { test.setTimeout(120_000) - const {host, hostButton} = await openPanelOverFocusedHostButton(page) + const {hostButton} = await openPanelOverFocusedHostButton(page) await page.getByRole('button', {name: 'Minimize conciv chat'}).click() await expect(hostButton).toBeFocused({timeout: 10_000}) - await host.close() }) test('closing via the panel header restores the host element that was focused before the open', async ({page}) => { test.setTimeout(120_000) - const {host, hostButton} = await openPanelOverFocusedHostButton(page) + const {hostButton} = await openPanelOverFocusedHostButton(page) await page.getByRole('button', {name: 'Close chat'}).click() await expect(hostButton).toBeFocused({timeout: 10_000}) - await host.close() }) test('collapsing the panel by dragging its resize handle shut restores the host element', async ({page}) => { test.setTimeout(180_000) - const {host, hostButton} = await openPanelOverFocusedHostButton(page) + const {hostButton} = await openPanelOverFocusedHostButton(page) const handle = page.getByRole('separator', {name: 'Resize chat height'}) const grip = await handle.boundingBox() @@ -80,7 +85,6 @@ test.describe('panel close restores focus: host element captured at open wins, F await page.mouse.up() await expect(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeHidden({timeout: 30_000}) await expect(hostButton).toBeFocused({timeout: 10_000}) - await host.close() }) test('closing via the FAB falls back to FAB focus when no host element was captured at open time', async ({page}) => { diff --git a/packages/embed/tests/helpers/host.ts b/packages/embed/tests/helpers/host.ts index 294dafaad..2334353ff 100644 --- a/packages/embed/tests/helpers/host.ts +++ b/packages/embed/tests/helpers/host.ts @@ -53,9 +53,9 @@ export async function listenLocal(server: Server): Promise<{base: string; port: base: `http://127.0.0.1:${port}`, port, close: () => - new Promise((resolve) => { + new Promise((resolve, reject) => { server.closeAllConnections?.() - server.close(() => resolve()) + server.close((error) => (error ? reject(error) : resolve())) }), } }