diff --git a/apps/conciv/package.json b/apps/conciv/package.json index 021e1e17f..f9190cb03 100644 --- a/apps/conciv/package.json +++ b/apps/conciv/package.json @@ -50,6 +50,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@conciv/browser-fixture": "workspace:*", "@conciv/extension-page": "workspace:^", "@conciv/extension-terminal": "workspace:^", "@conciv/extension-testkit": "workspace:^", diff --git a/apps/conciv/test/transport-standalone.it.test.ts b/apps/conciv/test/transport-standalone.it.test.ts index 738f1d775..a49a3b52e 100644 --- a/apps/conciv/test/transport-standalone.it.test.ts +++ b/apps/conciv/test/transport-standalone.it.test.ts @@ -1,6 +1,7 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' +import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import type {Browser, Page} from 'playwright' +import {test as browserTest} from '@conciv/browser-fixture' import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit' import {httpRpcRequestUrls, observeRpc, type RpcObserver} from '@conciv/extension-testkit/rpc-observer' import {serveStandaloneApp} from './helpers/static-app.js' @@ -8,34 +9,47 @@ import {proxyTo, type ProxyCore} from './helpers/proxy.js' const ASSISTANT_TEXT = 'Hello from standalone conciv' const MOUNT_TIMEOUT_MS = 30_000 +const SUITE_SETUP_TIMEOUT_MS = 90_000 -let browser: Browser -let kit: CoreKit -let openCore: ProxyCore -let app: {base: string; close: () => Promise} - -beforeAll(async () => { - browser = await chromium.launch() - kit = await bootCoreKit({id: 'standalone-transport', text: ASSISTANT_TEXT}) - openCore = await proxyTo(kit.base) - app = await serveStandaloneApp() -}, 90_000) - -afterAll(async () => { - await browser.close() - await app.close() - await openCore.close() - await kit.cleanup() +const test = browserTest.extend<{ + $file: {kit: CoreKit; openCore: ProxyCore; app: {base: string; close: () => Promise}} +}>({ + kit: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const kit = await bootCoreKit({id: 'standalone-transport', text: ASSISTANT_TEXT}) + await use(kit) + await kit.cleanup() + }, + {scope: 'file'}, + ], + openCore: [ + async ({kit}, use) => { + const openCore = await proxyTo(kit.base) + await use(openCore) + await openCore.close() + }, + {scope: 'file'}, + ], + app: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const app = await serveStandaloneApp() + await use(app) + await app.close() + }, + {scope: 'file'}, + ], }) -function pageUrl(coreBase: string, transport: 'websocket' | 'fetch'): string { +function pageUrl(appBase: string, coreBase: string, transport: 'websocket' | 'fetch'): string { const settings = encodeURIComponent(JSON.stringify({transport})) - return `${app.base}/?core=${encodeURIComponent(coreBase)}&settings=${settings}` + return `${appBase}/?core=${encodeURIComponent(coreBase)}&settings=${settings}` } type Tab = {page: Page; observer: RpcObserver; httpRpcUrls: string[]; disposeHttpRpc: () => void} -async function openTab(url: string): Promise { +async function openTab(browser: Browser, url: string): Promise { const page = await browser.newPage() const http = httpRpcRequestUrls(page) const observer = observeRpc(page) @@ -51,30 +65,38 @@ async function completeTurn(page: Page): Promise { await expectLocator(page.getByText(ASSISTANT_TEXT).first()).toBeVisible({timeout: MOUNT_TIMEOUT_MS}) } -describe('the standalone entry threads settings.transport into the browser rpc client', () => { - it('pins fetch and never opens a websocket when settings say transport: fetch', async () => { - const tab = await openTab(pageUrl(openCore.base, 'fetch')) - try { - await completeTurn(tab.page) - expect(tab.observer.socketCount()).toBe(0) - expect(tab.httpRpcUrls.length).toBeGreaterThan(0) - } finally { - tab.observer.dispose() - tab.disposeHttpRpc() - await tab.page.close() - } - }) +test.describe('the standalone entry threads settings.transport into the browser rpc client', () => { + test( + 'pins fetch and never opens a websocket when settings say transport: fetch', + async ({browser, app, openCore}) => { + const tab = await openTab(browser, pageUrl(app.base, openCore.base, 'fetch')) + try { + await completeTurn(tab.page) + expect(tab.observer.socketCount()).toBe(0) + expect(tab.httpRpcUrls.length).toBeGreaterThan(0) + } finally { + tab.observer.dispose() + tab.disposeHttpRpc() + await tab.page.close() + } + }, + SUITE_SETUP_TIMEOUT_MS, + ) - it('pins the websocket and never falls back to fetch when settings say transport: websocket', async () => { - const tab = await openTab(pageUrl(openCore.base, 'websocket')) - try { - await completeTurn(tab.page) - expect(tab.observer.socketCount()).toBe(1) - expect(tab.httpRpcUrls).toEqual([]) - } finally { - tab.observer.dispose() - tab.disposeHttpRpc() - await tab.page.close() - } - }) + test( + 'pins the websocket and never falls back to fetch when settings say transport: websocket', + async ({browser, app, openCore}) => { + const tab = await openTab(browser, pageUrl(app.base, openCore.base, 'websocket')) + try { + await completeTurn(tab.page) + expect(tab.observer.socketCount()).toBe(1) + expect(tab.httpRpcUrls).toEqual([]) + } finally { + tab.observer.dispose() + tab.disposeHttpRpc() + await tab.page.close() + } + }, + SUITE_SETUP_TIMEOUT_MS, + ) }) diff --git a/apps/conciv/vitest.config.ts b/apps/conciv/vitest.config.ts index 6fd8129df..c553d5494 100644 --- a/apps/conciv/vitest.config.ts +++ b/apps/conciv/vitest.config.ts @@ -46,6 +46,8 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.ts'], exclude: ['test/**/*.browser.test.ts', 'test/**/*.browser.test.tsx'], + testTimeout: ciTest().testTimeout, + hookTimeout: ciTest().hookTimeout, }, }, { diff --git a/apps/site/package.json b/apps/site/package.json index 2368800d3..25e5933c1 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.42.4", + "@conciv/browser-fixture": "workspace:*", "@conciv/core": "workspace:*", "@conciv/extension-compiler": "workspace:*", "@conciv/harness-testkit": "workspace:*", diff --git a/apps/site/test/live-connect.it.test.ts b/apps/site/test/live-connect.it.test.ts index 29c4b0fb9..c821f14cc 100644 --- a/apps/site/test/live-connect.it.test.ts +++ b/apps/site/test/live-connect.it.test.ts @@ -1,31 +1,24 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' +import {afterAll, expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser} from 'playwright' import {createFakeHarness} from '@conciv/harness-testkit' import {runConnect} from '@conciv/try' import type {Engine} from '@conciv/core/start' -import {startWranglerDev, type WranglerDev} from './wrangler-dev' +import {createSiteTest} from './site-fixture.js' const SITE_PORT = 8787 const INSPECTOR_PORT = 9787 const ORIGIN = `http://127.0.0.1:${SITE_PORT}` -let site: WranglerDev -let browser: Browser -let engine: Engine | null = null -beforeAll(async () => { - site = await startWranglerDev({port: SITE_PORT, inspectorPort: INSPECTOR_PORT}) - browser = await chromium.launch() -}, 120_000) +const test = createSiteTest({port: SITE_PORT, inspectorPort: INSPECTOR_PORT}) + +let engine: Engine | null = null afterAll(async () => { - await browser?.close() await engine?.stop() - await site?.stop() }) -describe('widget-native live connect on the built site', () => { - it('boots the widget into connect steps and hands off in place to live chat', async () => { +test.describe('widget-native live connect on the built site', () => { + test('boots the widget into connect steps and hands off in place to live chat', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) @@ -70,7 +63,7 @@ describe('widget-native live connect on the built site', () => { engine = null }, 180_000) - it('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async () => { + test('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) diff --git a/apps/site/test/mobile-gating.it.test.ts b/apps/site/test/mobile-gating.it.test.ts index 76a9800f5..fcc45d2b2 100644 --- a/apps/site/test/mobile-gating.it.test.ts +++ b/apps/site/test/mobile-gating.it.test.ts @@ -1,26 +1,16 @@ -import {afterAll, beforeAll, describe, expect, it} from 'vitest' +import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, devices, type Browser} from 'playwright' -import {startWranglerDev, type WranglerDev} from './wrangler-dev' +import {devices} from 'playwright' +import {createSiteTest} from './site-fixture.js' const SITE_PORT = 8788 const INSPECTOR_PORT = 9788 const ORIGIN = `http://127.0.0.1:${SITE_PORT}` -let site: WranglerDev -let browser: Browser -beforeAll(async () => { - site = await startWranglerDev({port: SITE_PORT, inspectorPort: INSPECTOR_PORT}) - browser = await chromium.launch() -}, 120_000) +const test = createSiteTest({port: SITE_PORT, inspectorPort: INSPECTOR_PORT}) -afterAll(async () => { - await browser?.close() - await site?.stop() -}) - -describe('landing gates the dev-only demo behind a non-mobile pointer', () => { - it('mounts the live widget and shows the install + try-it CTAs on desktop', async () => { +test.describe('landing gates the dev-only demo behind a non-mobile pointer', () => { + test('mounts the live widget and shows the install + try-it CTAs on desktop', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) @@ -31,7 +21,7 @@ describe('landing gates the dev-only demo behind a non-mobile pointer', () => { await page.close() }, 60_000) - it('does not mount the live widget or the CTAs on a mobile device', async () => { + test('does not mount the live widget or the CTAs on a mobile device', async ({browser}) => { const context = await browser.newContext(devices['iPhone 13']) const page = await context.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) @@ -44,8 +34,8 @@ describe('landing gates the dev-only demo behind a non-mobile pointer', () => { }, 60_000) }) -describe('the live widget mounts site-wide and the root widget param decides the panel', () => { - it('shows the launcher with the panel closed on a docs page on desktop', async () => { +test.describe('the live widget mounts site-wide and the root widget param decides the panel', () => { + test('shows the launcher with the panel closed on a docs page on desktop', async ({browser}) => { const page = await browser.newPage() await page.goto(`${ORIGIN}/docs/quick-start`, {waitUntil: 'domcontentloaded'}) @@ -55,7 +45,7 @@ describe('the live widget mounts site-wide and the root widget param decides the await page.close() }, 60_000) - it('auto-opens the panel on the home page without a widget param in the URL', async () => { + test('auto-opens the panel on the home page without a widget param in the URL', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) @@ -65,7 +55,7 @@ describe('the live widget mounts site-wide and the root widget param decides the await page.close() }, 60_000) - it('keeps the panel closed on the home page when ?widget=false is explicit', async () => { + test('keeps the panel closed on the home page when ?widget=false is explicit', async ({browser}) => { const page = await browser.newPage() await page.goto(`${ORIGIN}/?widget=false`, {waitUntil: 'domcontentloaded'}) @@ -75,7 +65,7 @@ describe('the live widget mounts site-wide and the root widget param decides the await page.close() }, 60_000) - it('keeps the open panel mounted while navigating from the landing page to the docs', async () => { + test('keeps the open panel mounted while navigating from the landing page to the docs', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) @@ -90,7 +80,7 @@ describe('the live widget mounts site-wide and the root widget param decides the await page.close() }, 60_000) - it('keeps a closed panel closed across navigation to the docs and back to the landing page', async () => { + test('keeps a closed panel closed across navigation to the docs and back to the landing page', async ({browser}) => { const page = await browser.newPage() await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) @@ -111,7 +101,7 @@ describe('the live widget mounts site-wide and the root widget param decides the await page.close() }, 60_000) - it('opens the panel on a docs page when ?widget=true is explicit', async () => { + test('opens the panel on a docs page when ?widget=true is explicit', async ({browser}) => { const page = await browser.newPage() await page.goto(`${ORIGIN}/docs/quick-start?widget=true`, {waitUntil: 'domcontentloaded'}) diff --git a/apps/site/test/site-fixture.ts b/apps/site/test/site-fixture.ts new file mode 100644 index 000000000..7ebac228f --- /dev/null +++ b/apps/site/test/site-fixture.ts @@ -0,0 +1,16 @@ +import {test as browserTest} from '@conciv/browser-fixture' +import {startWranglerDev, type WranglerDev} from './wrangler-dev.js' + +export function createSiteTest(options: {port: number; inspectorPort: number}) { + return browserTest.extend<{$file: {site: WranglerDev}}>({ + site: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const site = await startWranglerDev(options) + await use(site) + await site.stop() + }, + {scope: 'file', auto: true}, + ], + }) +} diff --git a/packages/browser-fixture/package.json b/packages/browser-fixture/package.json new file mode 100644 index 000000000..92d2d00cb --- /dev/null +++ b/packages/browser-fixture/package.json @@ -0,0 +1,30 @@ +{ + "name": "@conciv/browser-fixture", + "version": "0.0.18", + "private": true, + "description": "Internal test infra: a vitest test.extend() fixture that boots one playwright chromium instance per test file, with a p-timeout-bounded close. Zero @conciv/* dependencies by design, so any package can use it without risking a workspace dependency cycle.", + "homepage": "https://conciv.dev", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/conciv-dev/conciv.git", + "directory": "packages/browser-fixture" + }, + "type": "module", + "exports": { + ".": "./src/browser-fixture.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "oxlint" + }, + "dependencies": { + "p-timeout": "^7.0.1", + "playwright": "^1.61.1", + "vitest": "^4.1.8" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "typescript": "^6.0.3" + } +} diff --git a/packages/browser-fixture/src/browser-fixture.ts b/packages/browser-fixture/src/browser-fixture.ts new file mode 100644 index 000000000..fab4c6cca --- /dev/null +++ b/packages/browser-fixture/src/browser-fixture.ts @@ -0,0 +1,20 @@ +import {test as base} from 'vitest' +import {chromium, type Browser} from 'playwright' +import pTimeout from 'p-timeout' + +const BROWSER_CLOSE_TIMEOUT_MS = 30_000 + +export const test = base.extend<{$file: {browser: Browser}}>({ + browser: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const browser = await chromium.launch() + await use(browser) + await pTimeout(browser.close(), { + milliseconds: BROWSER_CLOSE_TIMEOUT_MS, + message: `browser.close did not settle within ${BROWSER_CLOSE_TIMEOUT_MS}ms; a wedged CDP connection would otherwise hang fixture cleanup forever (vitest test.extend cleanup is unbounded)`, + }) + }, + {scope: 'file'}, + ], +}) diff --git a/packages/browser-fixture/tsconfig.json b/packages/browser-fixture/tsconfig.json new file mode 100644 index 000000000..b69450326 --- /dev/null +++ b/packages/browser-fixture/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": ".tsbuildinfo", + "rootDir": ".", + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/extension-testkit/package.json b/packages/extension-testkit/package.json index 31455f9e8..056388808 100644 --- a/packages/extension-testkit/package.json +++ b/packages/extension-testkit/package.json @@ -31,6 +31,7 @@ "test": "vitest run" }, "dependencies": { + "@conciv/browser-fixture": "workspace:*", "@conciv/contract": "workspace:^", "@conciv/core": "workspace:^", "@conciv/extension": "workspace:^", diff --git a/packages/extension-testkit/src/get-extension-test-api.ts b/packages/extension-testkit/src/get-extension-test-api.ts index af53491a6..9e6e0eac9 100644 --- a/packages/extension-testkit/src/get-extension-test-api.ts +++ b/packages/extension-testkit/src/get-extension-test-api.ts @@ -11,6 +11,7 @@ import { type RunTypescript, } from '@conciv/harness-testkit' import {launch, openObservedPage} from './launch.js' +import {settleTeardown} from './settle-teardown.js' export type HostEngine = {apiBase: string; session: string} export type HostHandle = {origin: string; close: () => Promise} @@ -37,7 +38,7 @@ export type ExtensionTestApi = { dispose: () => Promise } -export {serveDir} from './serve.js' +export {serveDir, type ServedHost} from './serve.js' export {fixtureHost} from './fixture-host.js' export async function getExtensionTestApi(extension: ExtensionUnderTest): Promise { @@ -60,9 +61,7 @@ export async function getExtensionTestApi(extension: ExtensionUnderTest): Promis return {page: second, close: () => second.close()} }, dispose: async () => { - await closeBrowser() - await close() - await stop() + await settleTeardown([() => closeBrowser(), () => close(), () => stop()]) }, } } diff --git a/packages/extension-testkit/src/settle-teardown.ts b/packages/extension-testkit/src/settle-teardown.ts new file mode 100644 index 000000000..ca8cd1725 --- /dev/null +++ b/packages/extension-testkit/src/settle-teardown.ts @@ -0,0 +1,5 @@ +export async function settleTeardown(steps: Array<() => Promise>): Promise { + const results = await Promise.allSettled(steps.map((step) => step())) + const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (firstFailure) throw firstFailure.reason +} diff --git a/packages/extension-testkit/src/widget-suite.ts b/packages/extension-testkit/src/widget-suite.ts index 8adfa12c8..a1fc43a27 100644 --- a/packages/extension-testkit/src/widget-suite.ts +++ b/packages/extension-testkit/src/widget-suite.ts @@ -1,9 +1,10 @@ import fs from 'node:fs' import path from 'node:path' import {createServer, type Server} from 'node:http' -import {afterAll, beforeAll, describe, expect, it} from 'vitest' +import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import type {Browser, Page} from 'playwright' +import {test as browserTest} from '@conciv/browser-fixture' import {bootCoreKit, type CoreKit} from './core-kit.js' import {listenLocal} from './listen-local.js' @@ -17,6 +18,20 @@ const MIME: Record = { '.woff2': 'font/woff2', } +const GRACEFUL_STATIC_CLOSE_MS = 2_000 + +function closeStaticServer(server: Server, gracefulCloseMs: number): () => Promise { + return async () => { + const stopped = new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + const forceClose = setTimeout(() => server.closeAllConnections(), gracefulCloseMs) + try { + await stopped + } finally { + clearTimeout(forceClose) + } + } +} + export async function serveStaticDir(dir: string): Promise { const server: Server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost') @@ -33,45 +48,50 @@ export async function serveStaticDir(dir: string): Promise { const port = await listenLocal(server) return { base: `http://127.0.0.1:${port}`, - close: () => new Promise((resolve) => server.close(() => resolve())), + close: closeStaticServer(server, GRACEFUL_STATIC_CLOSE_MS), } } export function widgetComponentSuite(opts: {id: string; distDir: string}): void { - let browser: Browser - let kit: CoreKit - let host: ServedDir - - beforeAll(async () => { - browser = await chromium.launch() - kit = await bootCoreKit({id: opts.id}) - host = await serveStaticDir(opts.distDir) - }, 60_000) - - afterAll(async () => { - await browser.close() - await host.close() - await kit.cleanup() + const test = browserTest.extend<{$file: {kit: CoreKit; host: ServedDir}}>({ + kit: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const kit = await bootCoreKit({id: opts.id}) + await use(kit) + await kit.cleanup() + }, + {scope: 'file'}, + ], + host: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const host = await serveStaticDir(opts.distDir) + await use(host) + await host.close() + }, + {scope: 'file'}, + ], }) const fab = (page: Page) => page.getByRole('button', {name: 'Open conciv chat'}) - async function openPage(): Promise { + async function openPage(browser: Browser, host: ServedDir, kit: CoreKit): Promise { const page = await browser.newPage() await page.goto(`${host.base}/?core=${encodeURIComponent(kit.base)}`, {waitUntil: 'domcontentloaded'}) return page } - describe('ConcivWidget component', () => { - it('mounts exactly one widget', async () => { - const page = await openPage() + test.describe('ConcivWidget component', () => { + test('mounts exactly one widget', async ({browser, host, kit}) => { + const page = await openPage(browser, host, kit) await expectLocator(fab(page)).toHaveCount(1, {timeout: 30_000}) expect(await fab(page).count()).toBe(1) await page.close() }) - it('removing the component removes the widget, re-adding restores it', async () => { - const page = await openPage() + test('removing the component removes the widget, re-adding restores it', async ({browser, host, kit}) => { + const page = await openPage(browser, host, kit) await expectLocator(fab(page)).toBeVisible({timeout: 30_000}) await page.getByRole('button', {name: 'toggle widget'}).click() await expectLocator(fab(page)).toHaveCount(0, {timeout: 30_000}) @@ -80,8 +100,8 @@ export function widgetComponentSuite(opts: {id: string; distDir: string}): void await page.close() }) - it('a settings prop change remounts the widget with the new configuration', async () => { - const page = await openPage() + test('a settings prop change remounts the widget with the new configuration', async ({browser, host, kit}) => { + const page = await openPage(browser, host, kit) await expectLocator(fab(page)).toBeVisible({timeout: 30_000}) await page.getByRole('button', {name: 'open by default'}).click() await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 30_000}) diff --git a/packages/extensions/tanstack/package.json b/packages/extensions/tanstack/package.json index 88f0d9d74..134619e06 100644 --- a/packages/extensions/tanstack/package.json +++ b/packages/extensions/tanstack/package.json @@ -64,6 +64,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@conciv/browser-fixture": "workspace:*", "@conciv/core": "workspace:^", "@conciv/embed": "workspace:^", "@conciv/extension-compiler": "workspace:^", diff --git a/packages/extensions/tanstack/test/connect-parity.it.test.ts b/packages/extensions/tanstack/test/connect-parity.it.test.ts index c736141cc..bb2dff43e 100644 --- a/packages/extensions/tanstack/test/connect-parity.it.test.ts +++ b/packages/extensions/tanstack/test/connect-parity.it.test.ts @@ -1,11 +1,12 @@ import {fileURLToPath} from 'node:url' -import {afterAll, beforeAll, describe, expect, it} from 'vitest' +import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import type {Page} from 'playwright' import {z} from 'zod' +import {test as browserTest} from '@conciv/browser-fixture' import {bootCoreKit, type CoreKit} from '@conciv/extension-testkit/core-kit' import {completeConnectHandshake} from '@conciv/extension-testkit/connect-handshake' -import {serveDir} from '@conciv/extension-testkit' +import {serveDir, type ServedHost} from '@conciv/extension-testkit' import tanstackExtension from '../src/server.js' const hostDist = fileURLToPath(new URL('../dist/test-host', import.meta.url)) @@ -22,40 +23,55 @@ const loaderDataSchema = z.looseObject({ local: z.looseObject({n: z.number()}), }) -let browser: Browser -let kit: CoreKit -let host: {origin: string; close: () => Promise} -let page: Page - -describe('bootConnect: the tanstack client verbs answer the registry through the connect handle', () => { - beforeAll(async () => { - browser = await chromium.launch() - kit = await bootCoreKit({id: 'fake-tanstack', extensions: [tanstackExtension]}) - host = await serveDir(hostDist, {apiBase: '', session: await kit.session()}) - page = await browser.newPage() - await page.goto(host.origin, {waitUntil: 'domcontentloaded'}) - await page.getByRole('button', {name: 'Open conciv chat'}).click({timeout: 30_000}) - await completeConnectHandshake(page, kit.base) - }, 120_000) - - afterAll(async () => { - await page.close() - await host.close() - await kit.cleanup() - await browser.close() - }) - - it('tanstack.routerState reads the live TanStack app the connect handle attached to', async () => { - await page.getByRole('link', {name: 'About'}).click() - await expectLocator(page.getByRole('heading', {name: 'About this app'})).toBeVisible() - - const state = routerStateSchema.parse(await kit.rpc.registry.call({name: 'tanstack.routerState', input: {}})) - - expect(state.result.location.pathname).toBe('/about') - const aboutMatch = state.result.matches.find((match) => match.routeId === '/about') - if (!aboutMatch) throw new Error('the router state did not list the /about match') - const loaderData = loaderDataSchema.parse(aboutMatch.loaderData) - expect(loaderData.server.greeting).toBe('hello') - expect(loaderData.local.n).toBe(42) - }) +const CONNECT_SETUP_TIMEOUT_MS = 120_000 + +const test = browserTest.extend<{$file: {kit: CoreKit; host: ServedHost; connectedPage: Page}}>({ + kit: [ + // oxlint-disable-next-line no-empty-pattern -- vitest's fixture parser requires the literal `{}` destructuring + async ({}, use) => { + const kit = await bootCoreKit({id: 'fake-tanstack', extensions: [tanstackExtension]}) + await use(kit) + await kit.cleanup() + }, + {scope: 'file'}, + ], + host: [ + async ({kit}, use) => { + const host = await serveDir(hostDist, {apiBase: '', session: await kit.session()}) + await use(host) + await host.close() + }, + {scope: 'file'}, + ], + connectedPage: [ + async ({browser, host, kit}, use) => { + const page = await browser.newPage() + await page.goto(host.origin, {waitUntil: 'domcontentloaded'}) + await page.getByRole('button', {name: 'Open conciv chat'}).click({timeout: 30_000}) + await completeConnectHandshake(page, kit.base) + await use(page) + await page.close() + }, + {scope: 'file'}, + ], +}) + +test.describe('bootConnect: the tanstack client verbs answer the registry through the connect handle', () => { + test( + 'tanstack.routerState reads the live TanStack app the connect handle attached to', + async ({connectedPage, kit}) => { + await connectedPage.getByRole('link', {name: 'About'}).click() + await expectLocator(connectedPage.getByRole('heading', {name: 'About this app'})).toBeVisible() + + const state = routerStateSchema.parse(await kit.rpc.registry.call({name: 'tanstack.routerState', input: {}})) + + expect(state.result.location.pathname).toBe('/about') + const aboutMatch = state.result.matches.find((match) => match.routeId === '/about') + if (!aboutMatch) throw new Error('the router state did not list the /about match') + const loaderData = loaderDataSchema.parse(aboutMatch.loaderData) + expect(loaderData.server.greeting).toBe('hello') + expect(loaderData.local.n).toBe(42) + }, + CONNECT_SETUP_TIMEOUT_MS, + ) }) diff --git a/packages/ui-kit-system/package.json b/packages/ui-kit-system/package.json index f92d24843..abd584196 100644 --- a/packages/ui-kit-system/package.json +++ b/packages/ui-kit-system/package.json @@ -52,6 +52,7 @@ "lucide-solid": "^1.18.0" }, "devDependencies": { + "@conciv/browser-fixture": "workspace:*", "@conciv/uno-preset": "workspace:*", "@conciv/vitest-config": "workspace:*", "@types/node": "^22.19.21", diff --git a/packages/ui-kit-system/test/reduced-motion.it.test.ts b/packages/ui-kit-system/test/reduced-motion.it.test.ts index 53151852b..a6fc473ff 100644 --- a/packages/ui-kit-system/test/reduced-motion.it.test.ts +++ b/packages/ui-kit-system/test/reduced-motion.it.test.ts @@ -1,8 +1,7 @@ -import {chromium, type Browser} from 'playwright' import {expect as expectLocator} from 'playwright/test' -import {afterAll, beforeAll, it} from 'vitest' import {createGenerator} from 'unocss' import {presetConciv} from '@conciv/uno-preset' +import {test} from '@conciv/browser-fixture' const LOOPING: [string, string][] = [ ['Waiting', 'anim-dot1'], @@ -21,17 +20,7 @@ const uno = await createGenerator({presets: [presetConciv()]}) const {css} = await uno.generate(MARKUP) const PAGE = `${MARKUP}` -let browser: Browser - -beforeAll(async () => { - browser = await chromium.launch() -}) - -afterAll(async () => { - await browser.close() -}) - -it('keeps every looping shortcut animating when motion is welcome', async () => { +test('keeps every looping shortcut animating when motion is welcome', async ({browser}) => { const page = await browser.newPage({reducedMotion: 'no-preference'}) await page.setContent(PAGE) @@ -40,7 +29,7 @@ it('keeps every looping shortcut animating when motion is welcome', async () => await page.close() }) -it('stops every looping shortcut when the reader asks for reduced motion', async () => { +test('stops every looping shortcut when the reader asks for reduced motion', async ({browser}) => { const page = await browser.newPage({reducedMotion: 'reduce'}) await page.setContent(PAGE) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a769ad18a..a7d51eb9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../../packages/browser-fixture '@conciv/extension-page': specifier: workspace:^ version: link:../../packages/extensions/page @@ -509,6 +512,9 @@ importers: '@cloudflare/vite-plugin': specifier: ^1.42.4 version: 1.42.4(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.51.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260714.1)(wrangler@4.112.0) + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../../packages/browser-fixture '@conciv/core': specifier: workspace:* version: link:../../packages/core @@ -1312,6 +1318,25 @@ importers: specifier: ^8.1.1 version: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) + packages/browser-fixture: + dependencies: + p-timeout: + specifier: ^7.0.1 + version: 7.0.1 + playwright: + specifier: ^1.61.1 + version: 1.61.1 + vitest: + specifier: ^4.1.8 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + devDependencies: + '@types/node': + specifier: ^22.19.21 + version: 22.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/bundle-size: devDependencies: '@conciv/vitest-config': @@ -1928,6 +1953,9 @@ importers: packages/extension-testkit: dependencies: + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../browser-fixture '@conciv/contract': specifier: workspace:^ version: link:../contract @@ -2266,6 +2294,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../../browser-fixture '@conciv/core': specifier: workspace:^ version: link:../../core @@ -3794,6 +3825,9 @@ importers: specifier: ^1.18.0 version: 1.18.0(solid-js@1.9.14) devDependencies: + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../browser-fixture '@conciv/uno-preset': specifier: workspace:* version: link:../uno-preset @@ -15290,10 +15324,6 @@ packages: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -28810,7 +28840,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001806 - postcss: 8.5.19 + postcss: 8.5.25 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) @@ -28838,7 +28868,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001806 - postcss: 8.5.19 + postcss: 8.5.25 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) @@ -29863,12 +29893,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.19: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.25: dependencies: nanoid: 3.3.16