From de01830e5c437e8fe969ef86d54c1fb21986fec0 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:20:32 +0300 Subject: [PATCH 1/5] fix(test-teardown): harden playwright suite teardown against CI leaks Closes the first tranche of #389's teardown audit (epic #409): - extension-testkit, embed suite helpers, and probe-suite share one bounded-close/settle-all teardown (browser?.close() raced against a 30s deadline so a wedged CDP close can't eat the hook, remaining steps still run and settle before the first failure is rethrown). The three near-identical browser+kit+host suite lifecycles are now one shared manageBrowserSuite in extension-testkit. - afterAll hooks get an explicit timeout matching their paired beforeAll's, so vitest's default hook timeout can't cut teardown short. - serveStaticDir's static host adopts packages/serve's bounded graceful-close + closeAllConnections force-close pattern. - get-extension-test-api dispose() settles browser/host/engine teardown independently instead of aborting on the first rejection. - apps/conciv/vitest.config.ts restates testTimeout/hookTimeout on the unit project explicitly, matching ui-kit-system's pattern; cross-project inheritance in vitest is not guaranteed. chromium.launch() itself needs no compensation: playwright's _launchProcess already kills any spawned process before rethrowing a launch failure (verified in playwright-core's coreBundle.js). Co-Authored-By: Claude Fable 5 --- apps/conciv/vitest.config.ts | 2 + packages/embed/test/helpers/probe-suite.ts | 30 +++----- packages/embed/test/helpers/suite.ts | 24 ++---- packages/extension-testkit/package.json | 1 + .../extension-testkit/src/bounded-close.ts | 77 +++++++++++++++++++ .../src/get-extension-test-api.ts | 5 +- .../extension-testkit/src/widget-suite.ts | 46 ++++++----- 7 files changed, 124 insertions(+), 61 deletions(-) create mode 100644 packages/extension-testkit/src/bounded-close.ts 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/packages/embed/test/helpers/probe-suite.ts b/packages/embed/test/helpers/probe-suite.ts index 2d3183b2a..306b8e3b1 100644 --- a/packages/embed/test/helpers/probe-suite.ts +++ b/packages/embed/test/helpers/probe-suite.ts @@ -1,5 +1,5 @@ -import {afterAll, beforeAll} from 'vitest' -import {chromium, type Browser} from 'playwright' +import type {Browser} from 'playwright' +import {manageBrowserSuite} from '@conciv/extension-testkit/bounded-close' import {bootEmbedKit, type EmbedKit} from './boot.js' import {serveHost, wsProbeHostPage} from './host.js' @@ -11,26 +11,16 @@ export type ProbeSuite = { } export function setupWsProbeSuite(): ProbeSuite { - let browser: Browser - let kit: EmbedKit - let host: {base: string; close: () => Promise} - - beforeAll(async () => { - browser = await chromium.launch() - kit = await bootEmbedKit() - host = await serveHost(() => wsProbeHostPage()) - }, 60_000) - - afterAll(async () => { - await browser.close() - await host.close() - await kit.cleanup() + const suite = manageBrowserSuite Promise}>(async () => { + const kit = await bootEmbedKit() + const host = await serveHost(() => wsProbeHostPage()) + return {kit, host} }) return { - browser: () => browser, - kit: () => kit, - host: () => host, - socketUrl: () => `${kit.wsBase}/rpc-ws`, + browser: suite.browser, + kit: suite.kit, + host: suite.host, + socketUrl: () => `${suite.kit().wsBase}/rpc-ws`, } } diff --git a/packages/embed/test/helpers/suite.ts b/packages/embed/test/helpers/suite.ts index e2283174d..9825e88ea 100644 --- a/packages/embed/test/helpers/suite.ts +++ b/packages/embed/test/helpers/suite.ts @@ -1,5 +1,5 @@ -import {afterAll, beforeAll} from 'vitest' -import {chromium, type Browser} from 'playwright' +import type {Browser} from 'playwright' +import {manageBrowserSuite} from '@conciv/extension-testkit/bounded-close' import {bootEmbedKit, type EmbedKit} from './boot.js' import {hostPage, serveHost} from './host.js' @@ -10,21 +10,9 @@ export type WidgetSuite = { } 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() - kit = await bootEmbedKit(options) - host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}'})) - }, 60_000) - - afterAll(async () => { - await browser.close() - await host.close() - await kit.cleanup() + return manageBrowserSuite Promise}>(async () => { + const kit = await bootEmbedKit(options) + const host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}'})) + return {kit, host} }) - - return {browser: () => browser, kit: () => kit, host: () => host} } diff --git a/packages/extension-testkit/package.json b/packages/extension-testkit/package.json index 31455f9e8..2b6613d50 100644 --- a/packages/extension-testkit/package.json +++ b/packages/extension-testkit/package.json @@ -19,6 +19,7 @@ "./framework-fake": "./src/framework-fake.ts", "./host/grab": "./src/host/grab.ts", "./widget-suite": "./src/widget-suite.ts", + "./bounded-close": "./src/bounded-close.ts", "./test-host": "./src/test-host-config.ts", "./rpc-frames": "./src/rpc-frames.ts", "./rpc-observer": "./src/rpc-observer.ts", diff --git a/packages/extension-testkit/src/bounded-close.ts b/packages/extension-testkit/src/bounded-close.ts new file mode 100644 index 000000000..7fcef96b2 --- /dev/null +++ b/packages/extension-testkit/src/bounded-close.ts @@ -0,0 +1,77 @@ +import {afterAll, beforeAll} from 'vitest' +import {chromium, type Browser} from 'playwright' + +const DEFAULT_CLOSE_DEADLINE_MS = 30_000 + +export const BROWSER_CLOSE_TIMEOUT_MS = 30_000 +export const SUITE_HOOK_TIMEOUT_MS = 60_000 + +export async function boundedClose( + close: () => Promise, + label: string, + timeoutMs = DEFAULT_CLOSE_DEADLINE_MS, +): Promise { + const timerHolder: {timer: ReturnType | undefined} = {timer: undefined} + const timedOut = new Promise((_resolve, reject) => { + timerHolder.timer = setTimeout(() => reject(new Error(`${label} did not close within ${timeoutMs}ms`)), timeoutMs) + }) + try { + await Promise.race([close(), timedOut]) + } finally { + clearTimeout(timerHolder.timer) + } +} + +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 +} + +export type SuiteTeardownResources = { + browser: () => Promise + host: () => Promise + kit: () => Promise +} + +export function suiteTeardown(resources: SuiteTeardownResources): () => Promise { + return () => + settleTeardown([ + () => boundedClose(resources.browser, 'browser.close', BROWSER_CLOSE_TIMEOUT_MS), + resources.host, + resources.kit, + ]) +} + +export type ManagedBrowserSuite = { + browser: () => Browser + kit: () => Kit + host: () => Host +} + +export function manageBrowserSuite< + Kit extends {cleanup: () => Promise}, + Host extends {close: () => Promise}, +>(boot: () => Promise<{kit: Kit; host: Host}>): ManagedBrowserSuite { + let browser: Browser + let kit: Kit + let host: Host + + beforeAll(async () => { + browser = await chromium.launch() + const booted = await boot() + kit = booted.kit + host = booted.host + }, SUITE_HOOK_TIMEOUT_MS) + + afterAll( + suiteTeardown({ + browser: async () => await browser?.close(), + host: () => host.close(), + kit: () => kit.cleanup(), + }), + SUITE_HOOK_TIMEOUT_MS, + ) + + return {browser: () => browser, kit: () => kit, host: () => host} +} diff --git a/packages/extension-testkit/src/get-extension-test-api.ts b/packages/extension-testkit/src/get-extension-test-api.ts index af53491a6..3874e8a70 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 './bounded-close.js' export type HostEngine = {apiBase: string; session: string} export type HostHandle = {origin: string; close: () => 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/widget-suite.ts b/packages/extension-testkit/src/widget-suite.ts index 8adfa12c8..3cb0d1920 100644 --- a/packages/extension-testkit/src/widget-suite.ts +++ b/packages/extension-testkit/src/widget-suite.ts @@ -1,11 +1,12 @@ 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 {describe, expect, it} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' +import type {Page} from 'playwright' import {bootCoreKit, type CoreKit} from './core-kit.js' import {listenLocal} from './listen-local.js' +import {manageBrowserSuite} from './bounded-close.js' export type ServedDir = {base: string; close: () => Promise} @@ -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,32 +48,23 @@ 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 suite = manageBrowserSuite(async () => ({ + kit: await bootCoreKit({id: opts.id}), + host: await serveStaticDir(opts.distDir), + })) const fab = (page: Page) => page.getByRole('button', {name: 'Open conciv chat'}) async function openPage(): Promise { - const page = await browser.newPage() - await page.goto(`${host.base}/?core=${encodeURIComponent(kit.base)}`, {waitUntil: 'domcontentloaded'}) + const page = await suite.browser().newPage() + await page.goto(`${suite.host().base}/?core=${encodeURIComponent(suite.kit().base)}`, { + waitUntil: 'domcontentloaded', + }) return page } From baaa0a032131c7ce0c4f9052aa8e817e7babaf7d Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:23:18 +0300 Subject: [PATCH 2/5] revert(embed): drop vitest teardown hardening from embed/test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/embed/test/** is being migrated to @playwright/test as its runner by another agent — page/browser lifecycle there will be fixture-owned, so the vitest-side hook-timeout/settle-all hardening in suite.ts and probe-suite.ts is obsolete and would collide. Revert those two files to main; extension-testkit's shared bounded-close helper (manageBrowserSuite/settleTeardown/boundedClose) stays, used by widget-suite.ts and get-extension-test-api.ts. Co-Authored-By: Claude Fable 5 --- packages/embed/test/helpers/probe-suite.ts | 30 ++++++++++++++-------- packages/embed/test/helpers/suite.ts | 24 ++++++++++++----- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/embed/test/helpers/probe-suite.ts b/packages/embed/test/helpers/probe-suite.ts index 306b8e3b1..2d3183b2a 100644 --- a/packages/embed/test/helpers/probe-suite.ts +++ b/packages/embed/test/helpers/probe-suite.ts @@ -1,5 +1,5 @@ -import type {Browser} from 'playwright' -import {manageBrowserSuite} from '@conciv/extension-testkit/bounded-close' +import {afterAll, beforeAll} from 'vitest' +import {chromium, type Browser} from 'playwright' import {bootEmbedKit, type EmbedKit} from './boot.js' import {serveHost, wsProbeHostPage} from './host.js' @@ -11,16 +11,26 @@ export type ProbeSuite = { } export function setupWsProbeSuite(): ProbeSuite { - const suite = manageBrowserSuite Promise}>(async () => { - const kit = await bootEmbedKit() - const host = await serveHost(() => wsProbeHostPage()) - return {kit, host} + let browser: Browser + let kit: EmbedKit + let host: {base: string; close: () => Promise} + + beforeAll(async () => { + browser = await chromium.launch() + kit = await bootEmbedKit() + host = await serveHost(() => wsProbeHostPage()) + }, 60_000) + + afterAll(async () => { + await browser.close() + await host.close() + await kit.cleanup() }) return { - browser: suite.browser, - kit: suite.kit, - host: suite.host, - socketUrl: () => `${suite.kit().wsBase}/rpc-ws`, + browser: () => browser, + kit: () => kit, + host: () => host, + socketUrl: () => `${kit.wsBase}/rpc-ws`, } } diff --git a/packages/embed/test/helpers/suite.ts b/packages/embed/test/helpers/suite.ts index 9825e88ea..e2283174d 100644 --- a/packages/embed/test/helpers/suite.ts +++ b/packages/embed/test/helpers/suite.ts @@ -1,5 +1,5 @@ -import type {Browser} from 'playwright' -import {manageBrowserSuite} from '@conciv/extension-testkit/bounded-close' +import {afterAll, beforeAll} from 'vitest' +import {chromium, type Browser} from 'playwright' import {bootEmbedKit, type EmbedKit} from './boot.js' import {hostPage, serveHost} from './host.js' @@ -10,9 +10,21 @@ export type WidgetSuite = { } export function setupWidgetSuite(options: Parameters[0] = {}): WidgetSuite { - return manageBrowserSuite Promise}>(async () => { - const kit = await bootEmbedKit(options) - const host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}'})) - return {kit, host} + let browser: Browser + let kit: EmbedKit + let host: {base: string; close: () => Promise} + + beforeAll(async () => { + browser = await chromium.launch() + kit = await bootEmbedKit(options) + host = await serveHost(() => hostPage({apiBase: kit.base, widget: '{"quickTerminal":false}'})) + }, 60_000) + + afterAll(async () => { + await browser.close() + await host.close() + await kit.cleanup() }) + + return {browser: () => browser, kit: () => kit, host: () => host} } From 8d8ed1e5c143592ec16b306664f1c1d630ae1bad Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:39:40 +0300 Subject: [PATCH 3/5] refactor(extension-testkit): rebuild suite teardown on vitest fixtures + p-timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the widget-suite teardown hardening onto vitest 4's native test.extend() fixtures instead of hand-rolled orchestration, per review feedback that manageBrowserSuite/settleTeardown-as-orchestrator/ suiteTeardown duplicated a primitive vitest already has. widgetComponentSuite(opts) now declares browser/kit/host as {scope: 'file'} fixtures (boots once per file, same as the old beforeAll) instead of manually wiring beforeAll/afterAll + accessor closures. Fixture dependency resolution gives "teardown runs even when a test fails" and correct close ordering for free — verified against @vitest/runner@4.1.10's chunk-artifact.js: file-scoped fixture cleanup always runs from runSuite's try/finally (~3168-3174), regardless of test outcome. That source read also surfaced two DISTINCT teardown-timeout subsystems in vitest 4.1.10, which is why one bounded call survives: - aroundAll/aroundEach hooks get a dedicated per-hook teardown timer (createTimeoutPromise in callAroundHooks, ~2666-2779), sourced from hookTimeout via getAroundHookTimeout (~904). - test.extend() fixture cleanup (what browser/kit/host use here) has no such wrapper: callFixtureCleanup (~251) is a bare reverse loop over cleanup callbacks, and its call site has no timeout either. A hung browser.close() inside a file-scoped fixture's teardown would therefore hang the whole file/worker forever with nothing to stop it. That's the one place bounded-close-style plumbing is provably still needed, so it stays — implemented with p-timeout (already resolved at 7.0.1 in the lockfile via p-queue, pinned exact as a direct devDependency) instead of a hand-rolled timer/race. bounded-close.ts is renamed settle-teardown.ts and reduced to just settleTeardown, still used by get-extension-test-api.ts's dispose() (not vitest-hook-based, fixtures don't apply there). Co-Authored-By: Claude Fable 5 --- packages/extension-testkit/package.json | 2 +- .../extension-testkit/src/bounded-close.ts | 77 ------------------- .../src/get-extension-test-api.ts | 2 +- .../extension-testkit/src/settle-teardown.ts | 5 ++ .../extension-testkit/src/widget-suite.ts | 65 +++++++++++----- pnpm-lock.yaml | 3 + 6 files changed, 56 insertions(+), 98 deletions(-) delete mode 100644 packages/extension-testkit/src/bounded-close.ts create mode 100644 packages/extension-testkit/src/settle-teardown.ts diff --git a/packages/extension-testkit/package.json b/packages/extension-testkit/package.json index 2b6613d50..ca5429682 100644 --- a/packages/extension-testkit/package.json +++ b/packages/extension-testkit/package.json @@ -19,7 +19,6 @@ "./framework-fake": "./src/framework-fake.ts", "./host/grab": "./src/host/grab.ts", "./widget-suite": "./src/widget-suite.ts", - "./bounded-close": "./src/bounded-close.ts", "./test-host": "./src/test-host-config.ts", "./rpc-frames": "./src/rpc-frames.ts", "./rpc-observer": "./src/rpc-observer.ts", @@ -59,6 +58,7 @@ "devDependencies": { "@conciv/vitest-config": "workspace:*", "@types/node": "^22.19.21", + "p-timeout": "7.0.1", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/extension-testkit/src/bounded-close.ts b/packages/extension-testkit/src/bounded-close.ts deleted file mode 100644 index 7fcef96b2..000000000 --- a/packages/extension-testkit/src/bounded-close.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {afterAll, beforeAll} from 'vitest' -import {chromium, type Browser} from 'playwright' - -const DEFAULT_CLOSE_DEADLINE_MS = 30_000 - -export const BROWSER_CLOSE_TIMEOUT_MS = 30_000 -export const SUITE_HOOK_TIMEOUT_MS = 60_000 - -export async function boundedClose( - close: () => Promise, - label: string, - timeoutMs = DEFAULT_CLOSE_DEADLINE_MS, -): Promise { - const timerHolder: {timer: ReturnType | undefined} = {timer: undefined} - const timedOut = new Promise((_resolve, reject) => { - timerHolder.timer = setTimeout(() => reject(new Error(`${label} did not close within ${timeoutMs}ms`)), timeoutMs) - }) - try { - await Promise.race([close(), timedOut]) - } finally { - clearTimeout(timerHolder.timer) - } -} - -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 -} - -export type SuiteTeardownResources = { - browser: () => Promise - host: () => Promise - kit: () => Promise -} - -export function suiteTeardown(resources: SuiteTeardownResources): () => Promise { - return () => - settleTeardown([ - () => boundedClose(resources.browser, 'browser.close', BROWSER_CLOSE_TIMEOUT_MS), - resources.host, - resources.kit, - ]) -} - -export type ManagedBrowserSuite = { - browser: () => Browser - kit: () => Kit - host: () => Host -} - -export function manageBrowserSuite< - Kit extends {cleanup: () => Promise}, - Host extends {close: () => Promise}, ->(boot: () => Promise<{kit: Kit; host: Host}>): ManagedBrowserSuite { - let browser: Browser - let kit: Kit - let host: Host - - beforeAll(async () => { - browser = await chromium.launch() - const booted = await boot() - kit = booted.kit - host = booted.host - }, SUITE_HOOK_TIMEOUT_MS) - - afterAll( - suiteTeardown({ - browser: async () => await browser?.close(), - host: () => host.close(), - kit: () => kit.cleanup(), - }), - SUITE_HOOK_TIMEOUT_MS, - ) - - return {browser: () => browser, kit: () => kit, host: () => host} -} diff --git a/packages/extension-testkit/src/get-extension-test-api.ts b/packages/extension-testkit/src/get-extension-test-api.ts index 3874e8a70..2317268e0 100644 --- a/packages/extension-testkit/src/get-extension-test-api.ts +++ b/packages/extension-testkit/src/get-extension-test-api.ts @@ -11,7 +11,7 @@ import { type RunTypescript, } from '@conciv/harness-testkit' import {launch, openObservedPage} from './launch.js' -import {settleTeardown} from './bounded-close.js' +import {settleTeardown} from './settle-teardown.js' export type HostEngine = {apiBase: string; session: string} export type HostHandle = {origin: string; close: () => Promise} 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 3cb0d1920..e4139dee4 100644 --- a/packages/extension-testkit/src/widget-suite.ts +++ b/packages/extension-testkit/src/widget-suite.ts @@ -1,12 +1,12 @@ import fs from 'node:fs' import path from 'node:path' import {createServer, type Server} from 'node:http' -import {describe, expect, it} from 'vitest' +import {test as base, expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import type {Page} from 'playwright' +import {chromium, type Browser, type Page} from 'playwright' +import pTimeout from 'p-timeout' import {bootCoreKit, type CoreKit} from './core-kit.js' import {listenLocal} from './listen-local.js' -import {manageBrowserSuite} from './bounded-close.js' export type ServedDir = {base: string; close: () => Promise} @@ -19,6 +19,7 @@ const MIME: Record = { } const GRACEFUL_STATIC_CLOSE_MS = 2_000 +const BROWSER_CLOSE_TIMEOUT_MS = 30_000 function closeStaticServer(server: Server, gracefulCloseMs: number): () => Promise { return async () => { @@ -53,31 +54,57 @@ export async function serveStaticDir(dir: string): Promise { } export function widgetComponentSuite(opts: {id: string; distDir: string}): void { - const suite = manageBrowserSuite(async () => ({ - kit: await bootCoreKit({id: opts.id}), - host: await serveStaticDir(opts.distDir), - })) + const test = base.extend<{$file: {browser: Browser; kit: CoreKit; host: ServedDir}}>({ + 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'}, + ], + 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 { - const page = await suite.browser().newPage() - await page.goto(`${suite.host().base}/?core=${encodeURIComponent(suite.kit().base)}`, { - waitUntil: 'domcontentloaded', - }) + 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}) @@ -86,8 +113,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/pnpm-lock.yaml b/pnpm-lock.yaml index a769ad18a..16fc30190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2004,6 +2004,9 @@ importers: '@types/node': specifier: ^22.19.21 version: 22.20.0 + p-timeout: + specifier: 7.0.1 + version: 7.0.1 typescript: specifier: ^6.0.3 version: 6.0.3 From e1a3a014ebd894b6e91e78980201d64543b70303 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:56:58 +0300 Subject: [PATCH 4/5] refactor(test): migrate remaining hand-launched chromium suites onto the shared browser fixture Extends the vitest fixture rework to every other test file in the repo that still hand-launched chromium via beforeAll/afterAll, per coverage-gap inventory on #389 (shard-2). New @conciv/extension-testkit/browser-fixture exports a minimal `test` with just a {scope: 'file'} browser fixture (bounded close via p-timeout, same as widget-suite.ts) so consumers that only need a browser+page get that shape without dragging kit/host along. widget-suite.ts now builds on this shared base instead of duplicating the browser fixture. Migrated: - packages/extensions/tanstack/test/connect-parity.it.test.ts: kit, host and the already-connected page are file-scoped fixtures layered on the shared browser fixture. - apps/conciv/test/transport-standalone.it.test.ts (found via the repo-wide chromium.launch() grep, not itself in the original four-file list, but needed to make the "zero outside extension-testkit" claim true): kit, the proxy, and the static app host are file-scoped fixtures. - apps/site/test/{live-connect,mobile-gating}.it.test.ts: a new local apps/site/test/site-fixture.ts factors the shared startWranglerDev/stop() lifecycle (already SIGTERM->SIGKILL safe, no p-timeout needed there) into an auto file-scoped `site` fixture layered on the shared browser fixture, reused by both files. live-connect's per-test `engine` stays a plain module-level variable with its existing afterAll safety net: it's created and torn down inside a single test body, not a file-scoped resource. apps/site gains @conciv/extension-testkit as a devDependency (verified non-circular: extension-testkit does not depend on site). NOT migrated: packages/ui-kit-system/test/reduced-motion.it.test.ts. extension-testkit depends on @conciv/ui-kit-system, so adding extension-testkit as a devDependency of ui-kit-system would be circular (ui-kit-system -> extension-testkit -> ui-kit-system). Left as a hand-managed chromium.launch() suite; flagging for a follow-up that extracts a browser-only fixture into a package neither side already depends on, or moves it under extension-testkit in the other direction. Repo-wide `grep -rln "chromium.launch()"` after this change: only extension-testkit/src (in-scope), packages/embed/test/** (excluded, migrating to @playwright/test in a parallel PR), packages/ui-kit-system/test/reduced-motion.it.test.ts (excluded, circular dependency, see above), and extensions/recorder/src/server/render.ts (excluded, product code). Co-Authored-By: Claude Fable 5 --- .../test/transport-standalone.it.test.ts | 116 +++++++++++------- apps/site/package.json | 1 + apps/site/test/live-connect.it.test.ts | 23 ++-- apps/site/test/mobile-gating.it.test.ts | 38 +++--- apps/site/test/site-fixture.ts | 16 +++ packages/extension-testkit/package.json | 1 + .../extension-testkit/src/browser-fixture.ts | 20 +++ .../src/get-extension-test-api.ts | 2 +- .../extension-testkit/src/widget-suite.ts | 21 +--- .../tanstack/test/connect-parity.it.test.ts | 94 ++++++++------ pnpm-lock.yaml | 41 +++---- 11 files changed, 206 insertions(+), 167 deletions(-) create mode 100644 apps/site/test/site-fixture.ts create mode 100644 packages/extension-testkit/src/browser-fixture.ts diff --git a/apps/conciv/test/transport-standalone.it.test.ts b/apps/conciv/test/transport-standalone.it.test.ts index 738f1d775..e0d5c16c5 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/extension-testkit/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/site/package.json b/apps/site/package.json index 2368800d3..4bd8dfe8c 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -61,6 +61,7 @@ "@cloudflare/vite-plugin": "^1.42.4", "@conciv/core": "workspace:*", "@conciv/extension-compiler": "workspace:*", + "@conciv/extension-testkit": "workspace:*", "@conciv/harness-testkit": "workspace:*", "@conciv/try": "workspace:*", "@conciv/vitest-config": "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..76e31e911 --- /dev/null +++ b/apps/site/test/site-fixture.ts @@ -0,0 +1,16 @@ +import {test as browserTest} from '@conciv/extension-testkit/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/extension-testkit/package.json b/packages/extension-testkit/package.json index ca5429682..dd15066b4 100644 --- a/packages/extension-testkit/package.json +++ b/packages/extension-testkit/package.json @@ -13,6 +13,7 @@ "type": "module", "exports": { ".": "./src/get-extension-test-api.ts", + "./browser-fixture": "./src/browser-fixture.ts", "./card-harness": "./src/card-harness.tsx", "./connect-handshake": "./src/connect-handshake.ts", "./core-kit": "./src/core-kit.ts", diff --git a/packages/extension-testkit/src/browser-fixture.ts b/packages/extension-testkit/src/browser-fixture.ts new file mode 100644 index 000000000..fab4c6cca --- /dev/null +++ b/packages/extension-testkit/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/extension-testkit/src/get-extension-test-api.ts b/packages/extension-testkit/src/get-extension-test-api.ts index 2317268e0..9e6e0eac9 100644 --- a/packages/extension-testkit/src/get-extension-test-api.ts +++ b/packages/extension-testkit/src/get-extension-test-api.ts @@ -38,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 { diff --git a/packages/extension-testkit/src/widget-suite.ts b/packages/extension-testkit/src/widget-suite.ts index e4139dee4..b700496de 100644 --- a/packages/extension-testkit/src/widget-suite.ts +++ b/packages/extension-testkit/src/widget-suite.ts @@ -1,10 +1,10 @@ import fs from 'node:fs' import path from 'node:path' import {createServer, type Server} from 'node:http' -import {test as base, expect} from 'vitest' +import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' -import {chromium, type Browser, type Page} from 'playwright' -import pTimeout from 'p-timeout' +import type {Browser, Page} from 'playwright' +import {test as browserTest} from './browser-fixture.js' import {bootCoreKit, type CoreKit} from './core-kit.js' import {listenLocal} from './listen-local.js' @@ -19,7 +19,6 @@ const MIME: Record = { } const GRACEFUL_STATIC_CLOSE_MS = 2_000 -const BROWSER_CLOSE_TIMEOUT_MS = 30_000 function closeStaticServer(server: Server, gracefulCloseMs: number): () => Promise { return async () => { @@ -54,19 +53,7 @@ export async function serveStaticDir(dir: string): Promise { } export function widgetComponentSuite(opts: {id: string; distDir: string}): void { - const test = base.extend<{$file: {browser: Browser; kit: CoreKit; host: ServedDir}}>({ - 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'}, - ], + 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) => { diff --git a/packages/extensions/tanstack/test/connect-parity.it.test.ts b/packages/extensions/tanstack/test/connect-parity.it.test.ts index c736141cc..2e567b7f9 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/extension-testkit/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/pnpm-lock.yaml b/pnpm-lock.yaml index 16fc30190..c2c16f025 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,6 +515,9 @@ importers: '@conciv/extension-compiler': specifier: workspace:* version: link:../../packages/extension-compiler + '@conciv/extension-testkit': + specifier: workspace:* + version: link:../../packages/extension-testkit '@conciv/harness-testkit': specifier: workspace:* version: link:../../packages/harness-testkit @@ -1015,7 +1018,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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)) + version: 0.8.3(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)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -1088,7 +1091,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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)) + version: 0.8.3(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)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -15293,10 +15296,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} @@ -19740,9 +19739,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.131.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@oxc-parser/binding-wasm32-wasi@0.120.0': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -22063,14 +22062,14 @@ snapshots: '@tanstack/pacer-lite': 0.2.2 typescript: 6.0.3 - '@tanstack/devtools-bundler-core@0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@tanstack/devtools-bundler-core@0.1.1': dependencies: '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.2 chalk: 5.6.2 launch-editor: 2.14.1 magic-string: 0.30.21 - oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.120.0 picomatch: 4.0.5 transitivePeerDependencies: - '@emnapi/core' @@ -22122,9 +22121,9 @@ snapshots: - bufferutil - utf-8-validate - '@tanstack/devtools-vite@0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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))': + '@tanstack/devtools-vite@0.8.3(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))': dependencies: - '@tanstack/devtools-bundler-core': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@tanstack/devtools-bundler-core': 0.1.1 '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.2 chalk: 5.6.2 @@ -23973,9 +23972,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.1 - vitest: 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)) + vitest: 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)(sass@1.51.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.10(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))(vitest@4.1.10) + '@vitest/browser': 4.1.10(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))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -28813,7 +28812,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) @@ -28841,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.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) @@ -29426,7 +29425,7 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxc-parser@0.120.0: dependencies: '@oxc-project/types': 0.120.0 optionalDependencies: @@ -29446,7 +29445,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.120.0 '@oxc-parser/binding-linux-x64-musl': 0.120.0 '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-wasm32-wasi': 0.120.0 '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 '@oxc-parser/binding-win32-x64-msvc': 0.120.0 @@ -29866,12 +29865,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 From 8c985627882ff83f3a8607bcd6d0ac2fedb594c0 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:26:34 +0300 Subject: [PATCH 5/5] refactor(test): extract @conciv/browser-fixture as a zero-@conciv-deps leaf, migrate reduced-motion onto it The extension-testkit-hosted browser fixture was itself a dead end: extension-testkit depends on @conciv/harness-testkit, and harness-testkit depends on @conciv/extension, which depends on @conciv/ui-kit-system - so hosting it in either extension-testkit or harness-testkit put ui-kit-system one or two hops from a real cycle the moment it took a devDependency on the fixture. Confirmed by turbo's own cycle detector on the harness-testkit attempt: Cyclic dependency detected: @conciv/ui-kit-system#build, @conciv/extension#build, @conciv/harness-testkit#build New packages/browser-fixture (@conciv/browser-fixture, private, manifest/build/tsconfig shape mirrors @conciv/vitest-config and @conciv/harness-testkit: no build step, exports point straight at src/*.ts, no test script since it has no tests of its own - it's exercised by every consumer's own suite): playwright ^1.61.1 + p-timeout ^7.0.1 as dependencies, vitest ^4.1.8 the same way extension-testkit declares it (no peerDependency-for-vitest precedent exists anywhere else in the repo to match instead). Zero @conciv/* dependencies by construction, so nothing downstream of it can cycle back. extension-testkit's browser-fixture.ts is deleted; its four consumers (widget-suite.ts, connect-parity.it.test.ts, transport-standalone.it.test.ts, site-fixture.ts) now import @conciv/browser-fixture directly - one canonical import path, not two. apps/site's now-unused @conciv/extension-testkit devDependency (only needed for the old import path) is removed. packages/ui-kit-system/test/reduced-motion.it.test.ts migrates onto the shared browser fixture (devDep @conciv/browser-fixture): the hand-rolled beforeAll/afterAll chromium.launch()/browser.close() lifecycle is gone, each test destructures {browser} and opens its own page inline (each test needs a different reducedMotion context option, so a shared page fixture doesn't fit - matches the file's original per-test page shape). Repo-wide `grep -rln "chromium.launch()"` after this change: only packages/browser-fixture/src/browser-fixture.ts (the fixture itself) and packages/extension-testkit/src/launch.ts (get-extension-test-api's page launcher, previously verified to need no compensation - playwright already guarantees process cleanup on launch failure) remain as in-scope implementation call sites. The only two exclusions left: packages/embed/test/** (migrating to @playwright/test in a parallel PR) and extensions/recorder/src/server/render.ts (product code). Co-Authored-By: Claude Fable 5 --- apps/conciv/package.json | 1 + .../test/transport-standalone.it.test.ts | 2 +- apps/site/package.json | 2 +- apps/site/test/site-fixture.ts | 2 +- packages/browser-fixture/package.json | 30 +++++++++ .../src/browser-fixture.ts | 0 packages/browser-fixture/tsconfig.json | 11 ++++ packages/extension-testkit/package.json | 3 +- .../extension-testkit/src/widget-suite.ts | 2 +- packages/extensions/tanstack/package.json | 1 + .../tanstack/test/connect-parity.it.test.ts | 2 +- packages/ui-kit-system/package.json | 1 + .../test/reduced-motion.it.test.ts | 17 +---- pnpm-lock.yaml | 64 +++++++++++++------ 14 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 packages/browser-fixture/package.json rename packages/{extension-testkit => browser-fixture}/src/browser-fixture.ts (100%) create mode 100644 packages/browser-fixture/tsconfig.json 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 e0d5c16c5..a49a3b52e 100644 --- a/apps/conciv/test/transport-standalone.it.test.ts +++ b/apps/conciv/test/transport-standalone.it.test.ts @@ -1,7 +1,7 @@ import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' import type {Browser, Page} from 'playwright' -import {test as browserTest} from '@conciv/extension-testkit/browser-fixture' +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' diff --git a/apps/site/package.json b/apps/site/package.json index 4bd8dfe8c..25e5933c1 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -59,9 +59,9 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.42.4", + "@conciv/browser-fixture": "workspace:*", "@conciv/core": "workspace:*", "@conciv/extension-compiler": "workspace:*", - "@conciv/extension-testkit": "workspace:*", "@conciv/harness-testkit": "workspace:*", "@conciv/try": "workspace:*", "@conciv/vitest-config": "workspace:*", diff --git a/apps/site/test/site-fixture.ts b/apps/site/test/site-fixture.ts index 76e31e911..7ebac228f 100644 --- a/apps/site/test/site-fixture.ts +++ b/apps/site/test/site-fixture.ts @@ -1,4 +1,4 @@ -import {test as browserTest} from '@conciv/extension-testkit/browser-fixture' +import {test as browserTest} from '@conciv/browser-fixture' import {startWranglerDev, type WranglerDev} from './wrangler-dev.js' export function createSiteTest(options: {port: number; inspectorPort: number}) { 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/extension-testkit/src/browser-fixture.ts b/packages/browser-fixture/src/browser-fixture.ts similarity index 100% rename from packages/extension-testkit/src/browser-fixture.ts rename to packages/browser-fixture/src/browser-fixture.ts 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 dd15066b4..056388808 100644 --- a/packages/extension-testkit/package.json +++ b/packages/extension-testkit/package.json @@ -13,7 +13,6 @@ "type": "module", "exports": { ".": "./src/get-extension-test-api.ts", - "./browser-fixture": "./src/browser-fixture.ts", "./card-harness": "./src/card-harness.tsx", "./connect-handshake": "./src/connect-handshake.ts", "./core-kit": "./src/core-kit.ts", @@ -32,6 +31,7 @@ "test": "vitest run" }, "dependencies": { + "@conciv/browser-fixture": "workspace:*", "@conciv/contract": "workspace:^", "@conciv/core": "workspace:^", "@conciv/extension": "workspace:^", @@ -59,7 +59,6 @@ "devDependencies": { "@conciv/vitest-config": "workspace:*", "@types/node": "^22.19.21", - "p-timeout": "7.0.1", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/extension-testkit/src/widget-suite.ts b/packages/extension-testkit/src/widget-suite.ts index b700496de..a1fc43a27 100644 --- a/packages/extension-testkit/src/widget-suite.ts +++ b/packages/extension-testkit/src/widget-suite.ts @@ -4,7 +4,7 @@ import {createServer, type Server} from 'node:http' import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' import type {Browser, Page} from 'playwright' -import {test as browserTest} from './browser-fixture.js' +import {test as browserTest} from '@conciv/browser-fixture' import {bootCoreKit, type CoreKit} from './core-kit.js' import {listenLocal} from './listen-local.js' 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 2e567b7f9..bb2dff43e 100644 --- a/packages/extensions/tanstack/test/connect-parity.it.test.ts +++ b/packages/extensions/tanstack/test/connect-parity.it.test.ts @@ -3,7 +3,7 @@ import {expect} from 'vitest' import {expect as expectLocator} from 'playwright/test' import type {Page} from 'playwright' import {z} from 'zod' -import {test as browserTest} from '@conciv/extension-testkit/browser-fixture' +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, type ServedHost} from '@conciv/extension-testkit' 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 c2c16f025..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,15 +512,15 @@ 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 '@conciv/extension-compiler': specifier: workspace:* version: link:../../packages/extension-compiler - '@conciv/extension-testkit': - specifier: workspace:* - version: link:../../packages/extension-testkit '@conciv/harness-testkit': specifier: workspace:* version: link:../../packages/harness-testkit @@ -1018,7 +1021,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.3(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)) + version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -1091,7 +1094,7 @@ importers: version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': specifier: latest - version: 0.8.3(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)) + version: 0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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)) '@tanstack/router-cli': specifier: ^1.132.0 version: 1.167.18 @@ -1315,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': @@ -1931,6 +1953,9 @@ importers: packages/extension-testkit: dependencies: + '@conciv/browser-fixture': + specifier: workspace:* + version: link:../browser-fixture '@conciv/contract': specifier: workspace:^ version: link:../contract @@ -2007,9 +2032,6 @@ importers: '@types/node': specifier: ^22.19.21 version: 22.20.0 - p-timeout: - specifier: 7.0.1 - version: 7.0.1 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2272,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 @@ -3800,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 @@ -19739,9 +19767,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.131.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.120.0': + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -22062,14 +22090,14 @@ snapshots: '@tanstack/pacer-lite': 0.2.2 typescript: 6.0.3 - '@tanstack/devtools-bundler-core@0.1.1': + '@tanstack/devtools-bundler-core@0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.2 chalk: 5.6.2 launch-editor: 2.14.1 magic-string: 0.30.21 - oxc-parser: 0.120.0 + oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) picomatch: 4.0.5 transitivePeerDependencies: - '@emnapi/core' @@ -22121,9 +22149,9 @@ snapshots: - bufferutil - utf-8-validate - '@tanstack/devtools-vite@0.8.3(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))': + '@tanstack/devtools-vite@0.8.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(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))': dependencies: - '@tanstack/devtools-bundler-core': 0.1.1 + '@tanstack/devtools-bundler-core': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.2 chalk: 5.6.2 @@ -23972,9 +24000,9 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.1 - vitest: 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)(sass@1.51.0)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 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)) optionalDependencies: - '@vitest/browser': 4.1.10(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))(vitest@4.1.10) + '@vitest/browser': 4.1.10(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))(vitest@4.1.10) '@vitest/expect@3.2.4': dependencies: @@ -29425,7 +29453,7 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.120.0: + oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.120.0 optionalDependencies: @@ -29445,7 +29473,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.120.0 '@oxc-parser/binding-linux-x64-musl': 0.120.0 '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0 + '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 '@oxc-parser/binding-win32-x64-msvc': 0.120.0