diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294d9fed1..df3e08be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,9 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm lint + # Rstest's default reporters under GITHUB_ACTIONS=true are `default` plus + # `github-actions`, so failures already annotate the PR; no --reporter + # flag is needed on these legs (verified on @rstest/core 0.11.10). - run: pnpm test # Required-check anchor for the Verify matrix. When a matrix job is skipped diff --git a/docs/architecture/rsc-runtime-workbench.md b/docs/architecture/rsc-runtime-workbench.md index f54e5884b..bd51a0547 100644 --- a/docs/architecture/rsc-runtime-workbench.md +++ b/docs/architecture/rsc-runtime-workbench.md @@ -49,6 +49,13 @@ packages/ src/dev/runtime-routes.ts src/dev/workbench-server.ts src/index.ts + tests/claude-hook-event-name.test.ts + tests/claude-hooks-schema.test.ts + tests/claude-plugin-validate-acceptance.test.ts + tests/claude-plugin-validation.test.ts + tests/codex-distribution.test.ts + tests/codex-hook-contract.test.ts + tests/codex-plugin-validation.test.ts tests/dev-artifact-service.test.ts tests/dev-workbench-packaging.test.ts tests/dev-workbench.test.ts @@ -68,6 +75,7 @@ packages/ tests/normalization.test.ts tests/playground-service.test.ts tests/portable-adapter.test.ts + tests/portable-plugin-validation.test.ts tests/public-api-packed.test.ts tests/public-api.test.ts tests/rsc-runtime-optional-packaging.test.ts @@ -114,12 +122,13 @@ packages/ tests/runtime-consent-dialog.test.ts tests/runtime-consent-queue.test.ts tests/runtime-contract-compile.test.ts + tests/runtime-document-atoms-disposal.test.ts tests/runtime-inspector.test.ts tests/runtime-mcp-handoff.test.ts tests/runtime-model.test.ts + tests/runtime-playground-capture-cleanup.test.ts tests/runtime-playground-capture.test.ts tests/runtime-playground-hmr.e2e.test.ts - tests/runtime-playground.browser.test.tsx tests/runtime-playground.e2e.test.ts tests/runtime-playground.test.ts tests/runtime-stage.test.ts @@ -134,6 +143,7 @@ examples/ src/build/emit-artifacts.ts src/build/serialize-definition.ts src/definition.ts + src/dev/canonical-json.ts src/dev/definition-entry.ts src/dev/environment-checkpoint-store.ts src/dev/generation-materializer.ts @@ -144,6 +154,7 @@ examples/ src/dev/serialize-inspection.ts src/flight/request-render.ts src/hook/cli.ts + src/hook/eval-probe.ts src/hook/normalize.ts src/hook/project-document.ts src/mcp/create-server.ts diff --git a/docs/assets/rsc-runtime-workbench/fidelity-ledger.md b/docs/assets/rsc-runtime-workbench/fidelity-ledger.md index 5cbb70533..c41482648 100644 --- a/docs/assets/rsc-runtime-workbench/fidelity-ledger.md +++ b/docs/assets/rsc-runtime-workbench/fidelity-ledger.md @@ -57,7 +57,7 @@ not a pass. | Typography | Compact sans-serif labels; monospace JSON/IDs/trace; small uppercase status tokens | Type hierarchy remains legible at the desktop width; code never depends on color alone | `desktop.png` visibly shows labels and monospace values, but no committed contrast or color-independence audit establishes the entire acceptance statement. | Not visually evidenced — visual hierarchy is shown; formal accessibility evidence is not. | | Palette | White canvas, near-black text, cool gray rules, cobalt active controls, green success, neutral disclaimer | Preserve contrast and restrained developer-tool palette; no gradients or marketing cards | The approved raster set visibly uses the restrained palette, but no committed contrast measurement establishes every stated accessibility property. | Not visually evidenced — visual appearance alone is insufficient for the contrast claim. | | Container model | 1px outlined panels, 4–6px corners, dense 4px rhythm, 10–16px padding/gutters | Panels align to one grid and retain accessible hit areas without card bloat | `desktop.png` shows outlined panels and the committed `styles.css` sizes Runtime controls at a 40px minimum, but no cited evidence establishes the complete grid/padding/no-bloat claim. | Not visually evidenced — only portions of the container acceptance are observed. | -| Focus/selected states | Cobalt outline/tab underline/current chip; selected Form, Tree, history, generation | Focus is visible, selected semantics are announced, and pointer-only state is not used | Keyboard focus restoration and selected states are exercised by `runtime-playground.e2e.test.ts` and `runtime-playground.browser.test.tsx`, but neither captures computed focus appearance or proves color-independent presentation. | Not visually evidenced — implementation behavior exists, but visual focus treatment is not verified. | +| Focus/selected states | Cobalt outline/tab underline/current chip; selected Form, Tree, history, generation | Focus is visible, selected semantics are announced, and pointer-only state is not used | Keyboard focus restoration and selected states are exercised by `runtime-playground.e2e.test.ts`, but it captures neither computed focus appearance nor color-independent presentation. | Not visually evidenced — implementation behavior exists, but visual focus treatment is not verified. | | Success/error states | Green dots/checks and `SUCCESS`; no error shown in the raster | Implement success and phase-labelled pale-red diagnostics; absence of an error in the concept is not evidence that error UI may be omitted | `compile-error.png` visibly records the phase-labelled AB8206 diagnostic and `recovered.png` shows its cleared text; `runtime-playground-hmr.e2e.test.ts` verifies the failure/recovery behavior. Neither cited raster nor test observes computed pale-red diagnostic styling. | Not visually evidenced — diagnostic phase/text behavior is directly observed, but the compound color-treatment acceptance is not. | | Stale/last-good states | Explicit no-stale message plus prior-generation `Last good` identity | Preserve last-good output through compile/run/App failure and distinguish stale from current | `compile-error.png` and `recovered.png`, backed by the capture contract and `runtime-playground-hmr.e2e.test.ts`, prove retention then recovery. | Verified | | 1100px continuation | Concept is native desktop only; approved continuation moves Inspector below stage | No unreadable third column; bounded trace scroll; identity labels remain separate | No approved 1100px raster or cited contract run covers this breakpoint. | Not visually evidenced — responsive evidence is limited to 1440×900 and 390×844. | diff --git a/package.json b/package.json index a809770ed..21e2b8266 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,6 @@ "@rslib/core": "0.23.2", "@rslint/core": "0.8.2", "@rstest/adapter-rslib": "0.11.10", - "@rstest/browser": "0.11.10", - "@rstest/browser-react": "0.11.10", "@rstest/core": "0.11.10", "@rstest/playwright": "0.11.10", "@types/node": "26.4.0", diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index a21647303..1a2b8d902 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -46,7 +46,6 @@ const runSourceCliWithOutput = async ( dependencies: CliDependencies = {}, ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runSourceCli(args, terminal.output, dependencies); return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; @@ -799,8 +798,6 @@ it('reports a generated Flight worker collision before compiling scripts', async it('dispatches the install command through the native installer surface', async () => { const terminal = captureCliTerminal(); const calls: unknown[] = []; - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runSourceCli( ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--json'], terminal.output, @@ -900,7 +897,6 @@ it('reports the bound server exiting on its own as one diagnostic and releases t let closeCalls = 0; const serverExit = Promise.withResolvers(); const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runSourceCli(['serve-app', 'status/status', '--root', '/project', '--no-open'], terminal.output, { serveApp: async () => ({ close: async () => { closeCalls += 1; }, diff --git a/packages/agent-bundle/tests/eval-cli.test.ts b/packages/agent-bundle/tests/eval-cli.test.ts index 6ef9eb02a..9d6f871c9 100644 --- a/packages/agent-bundle/tests/eval-cli.test.ts +++ b/packages/agent-bundle/tests/eval-cli.test.ts @@ -18,7 +18,6 @@ const runCliWithOutput = async (args: readonly string[]): Promise<{ readonly stdout: string; }> => { const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli([...args], terminal.output); return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index e5ef21c5f..088163508 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -41,7 +41,6 @@ const inspectCli = async ( args: readonly string[], ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli(['inspect', '--root', root, ...args], terminal.output); return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 4168ff41a..fbb29b065 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1453,8 +1453,6 @@ it('rejects a Cursor plugin name that could escape the local install root', asyn it('dispatches the public CLI install command to the native installer', async () => { const terminal = captureCliTerminal(); const calls: unknown[] = []; - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runCli( ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--force', '--json'], terminal.output, @@ -1774,7 +1772,6 @@ it('rejects an install mode for hosts other than Cursor', async () => { it('passes --mode through the public CLI and prints the staged next steps', async () => { const calls: unknown[] = []; - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const dependencies = { installBundle: async (options: unknown) => { calls.push(options); diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 4346bf20e..6a961eb19 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -131,7 +131,6 @@ it('prepack validates the complete dry-run inventory', async () => { it('exposes --root, --output, and --json through the prepack command', async () => { const calls: unknown[] = []; const terminal = captureCliTerminal(); - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli( ['prepack', '--root', projectRoot, '--output', 'host-packs', '--json'], terminal.output, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 9ee91a7cd..0707f82da 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -788,7 +788,6 @@ it('shows projected MCP command provenance and safety in the routes inspect focu }); it('dumps the graph through the CLI --routes focus and rejects ambiguous focuses', async () => { - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const root = await createInspectProject({ 'src/mcp/curator/tools/inspect.ts': moduleSource, }); diff --git a/packages/agent-bundle/tests/rstest-pool-lists.test.ts b/packages/agent-bundle/tests/rstest-pool-lists.test.ts new file mode 100644 index 000000000..7cd866e38 --- /dev/null +++ b/packages/agent-bundle/tests/rstest-pool-lists.test.ts @@ -0,0 +1,123 @@ +import { globSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from '@rstest/core'; + +import * as poolLists from '../../../rstest.integration-tests.ts'; + +/** + * The pools are defined by subtraction: `rstest.unit.config.ts` collects + * `workspaceTestFileGlob` minus every list `rstest.integration-tests.ts` + * exports, and each other pool includes exactly one of those lists. Nothing in + * Rstest checks that a listed path still exists, so a moved or renamed file + * would silently fall into the non-isolated, parallel unit pool while its + * stale entry kept excluding nothing — and a file the glob never matched (a + * `.tsx`, a `.spec.ts`) has no pool at all unless a list names it. This is the + * interim guard until the suites move into per-pool directories (#566). + */ +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** Workspace-relative, `/`-separated, whatever the platform hands back. */ +const toPosix = (path: string): string => path.split(sep).join('/'); + +const isGlob = (entry: string): boolean => /[*?[\]{}]/u.test(entry); + +const isTestFileName = (name: string): boolean => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(name); + +const isFile = (entry: string): boolean => { + try { + return statSync(resolve(workspaceRoot, entry)).isFile(); + } catch { + return false; + } +}; + +/** What Rstest's default `exclude` prunes, so a dependency's or build output's tests never count. */ +const prunedSegments: ReadonlySet = new Set(['node_modules', 'dist', '.idea', '.git', '.cache', '.output', '.temp']); + +const isPrunedPath = (path: string): boolean => toPosix(path).split('/').some((segment) => prunedSegments.has(segment)); + +const matchingFiles = (pattern: string): readonly string[] => globSync(pattern, { + cwd: workspaceRoot, + exclude: (path: string) => isPrunedPath(path), +}).map(toPosix).filter(isFile).sort(); + +/** Files a pool entry collects: the entry itself when literal, its matches when a glob. */ +const collectedFiles = (entry: string): readonly string[] => (isGlob(entry) ? matchingFiles(entry) : [entry]); + +/** + * Rstest globs with `dot: true`; Node's `fs.globSync` never enters or returns + * a dot path and has no option to. The two views agree exactly as long as no + * test file lives under a hidden path, so this walk — which does see dot + * paths, and prunes what Rstest prunes — reports every test file that breaks + * the agreement. + */ +const hiddenTestFiles = (directory: string): readonly string[] => readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + if (prunedSegments.has(entry.name)) return []; + const path = join(directory, entry.name); + if (entry.isDirectory()) return hiddenTestFiles(path); + if (!entry.isFile() || !isTestFileName(entry.name)) return []; + const relativePath = toPosix(relative(workspaceRoot, path)); + return relativePath.split('/').some((segment) => segment.startsWith('.')) ? [relativePath] : []; + }); + +const { workspaceTestFileGlob, ...exportedLists } = poolLists; +const lists = Object.entries(exportedLists) as ReadonlyArray; + +it('exports the include glob plus non-empty string lists, so every pool below is actually checked', () => { + expect(isGlob(workspaceTestFileGlob)).toBe(true); + expect(lists.length).toBeGreaterThan(0); + for (const [name, entries] of lists) { + expect(Array.isArray(entries), `${name} is not an array`).toBe(true); + expect(entries.length, `${name} is empty`).toBeGreaterThan(0); + expect(entries.every((entry) => typeof entry === 'string'), `${name} holds a non-string entry`).toBe(true); + } +}); + +it('keeps every test file on a visible path, so the glob checks below see what Rstest collects', () => { + expect(hiddenTestFiles(join(workspaceRoot, 'packages')), 'test files under a hidden path (Rstest collects them; this guard cannot)').toEqual([]); +}); + +describe.each(lists)('%s', (name, entries) => { + it('lists only files that exist (a moved or deleted test leaves a stale entry)', () => { + const missing = entries.filter((entry) => !isGlob(entry) && !isFile(entry)); + expect(missing, `${name}: paths that no longer exist`).toEqual([]); + }); + + it('lists only globs that still match at least one file', () => { + const empty = entries.filter((entry) => isGlob(entry) && collectedFiles(entry).length === 0); + expect(empty, `${name}: globs that match nothing`).toEqual([]); + }); + + it('lists every entry once', () => { + const duplicates = entries.filter((entry, index) => entries.indexOf(entry) !== index); + expect(duplicates, `${name}: repeated entries`).toEqual([]); + }); +}); + +it('assigns every test file to at most one pool', () => { + const owners = new Map(); + for (const [name, entries] of lists) { + for (const file of entries.flatMap(collectedFiles)) { + owners.set(file, [...(owners.get(file) ?? []), name]); + } + } + const shared = [...owners].filter(([, names]) => names.length > 1).map(([file, names]) => `${file} <- ${names.join(', ')}`); + expect(shared, 'files collected by more than one pool list').toEqual([]); +}); + +it('leaves no test file without a pool', () => { + // Every `.test.ts` the include glob matches has a pool by construction (the + // unit pool, unless a list claims it); anything else — another extension, + // another suffix — runs only if a list names it. `isTestFileName` is the + // full set Rstest's default include (`*.{test,spec}.?(c|m)[jt]s?(x)`) accepts. + const everyTestFile = matchingFiles('packages/**/tests/**/*.{test,spec}.*').filter((file) => isTestFileName(file)); + const collected = new Set([ + ...matchingFiles(workspaceTestFileGlob), + ...lists.flatMap(([, entries]) => entries.flatMap(collectedFiles)), + ]); + const orphans = everyTestFile.filter((file) => !collected.has(file)); + expect(orphans, 'test files no pool collects: add them to a list in rstest.integration-tests.ts or delete them').toEqual([]); +}); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 6166b339b..4b931f246 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -1249,7 +1249,6 @@ it('rejects an uninstall mode for hosts other than Cursor before touching anythi it('exposes uninstall through the public CLI with every lifecycle flag', async () => { const terminal = captureCliTerminal(); const calls: unknown[] = []; - Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const result: UninstallResult = { bundleRoot: '/tmp/example bundle', data: { detail: 'kept', outcome: 'kept', paths: ['/tmp/example bundle/state'], policy: 'keep' }, diff --git a/packages/workbench/tests/runtime-playground.browser.test.tsx b/packages/workbench/tests/runtime-playground.browser.test.tsx deleted file mode 100644 index da0f7650e..000000000 --- a/packages/workbench/tests/runtime-playground.browser.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { page } from '@rstest/browser'; -import { render } from '@rstest/browser-react'; -import { expect, test } from '@rstest/core'; -import React from 'react'; - -import type { - DevRuntimeInvocationRequest, - DevRuntimeReplayRequest, - DevRuntimeRun, - DevRuntimeStateIdentity, - DevRuntimeStateResetRequest, - DevRuntimeStatus, - DevRuntimeSurface, -} from '../../agent-bundle/src/dev/runtime-protocol.ts'; -import type { ProjectEventMessage } from '../../agent-bundle/src/dev/types.ts'; -import type { AgentRenderEvent } from '../src/runtime/agent-document-client.ts'; -import { createRuntimePlaygroundController, RuntimePlayground, type RuntimePlaygroundClient } from '../src/runtime-playground.tsx'; -import type { RuntimeProfileOption } from '../src/runtime-model.ts'; -import type { RuntimeBootstrap } from '../src/runtime-client.ts'; - -const vector = Object.freeze({ - providerSessionId: 'browser-provider', - runtimeGenerationId: 'browser-generation', - sourceRevision: 'browser-source', - stateStoreId: 'browser-state', - stateVersion: 1, -}); - -const status = Object.freeze({ - activeVector: vector, - descriptor: Object.freeze({ environmentVariables: [], id: 'rsc', label: 'RSC Runtime', schemaVersion: 1 as const }), - diagnostics: Object.freeze([]), - hmrReady: true, - lastGoodVector: vector, - state: 'active' as const, -}) satisfies DevRuntimeStatus; - -const surface = Object.freeze({ - defaultTarget: 'portable', - fixtures: Object.freeze([{ id: 'browser-fixture', label: 'Browser fixture' }]), - id: 'mcp.browser', - inputSchema: Object.freeze({ - properties: Object.freeze({ city: Object.freeze({ title: 'City', type: 'string' as const }) }), - required: Object.freeze(['city']), - type: 'object' as const, - }), - kind: 'mcp-tool' as const, - label: 'Browser tool', - readOnly: false, - targets: Object.freeze(['portable']), -}) satisfies DevRuntimeSurface; - -const run = (id: string): DevRuntimeRun => Object.freeze({ - completedAt: '2026-08-15T12:00:01.000Z', - fixtureId: 'browser-fixture', - id, - input: Object.freeze({ city: 'London' }), - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'browser-state', stateVersion: 1 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - startedAt: '2026-08-15T12:00:00.000Z', - status: 'succeeded', - surfaceId: 'mcp.browser', - target: 'portable', - vector, -}); - -const evidenceRun = (id: string): DevRuntimeRun => Object.freeze({ - ...run(id), - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - flight: Object.freeze({ bytes: 2, downloadPath: `/api/runtime/runs/${id}/flight`, preview: 'FL', truncated: false }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'browser-state', stateVersion: 1 }) }), - trace: Object.freeze([Object.freeze({ - details: Object.freeze({ step: 'render' }), - id: 'span-a', - phase: 'render', - startedAt: '2026-08-15T12:00:00.500Z', - status: 'succeeded' as const, - })]), - tree: Object.freeze([]), - }), -}) as DevRuntimeRun; - -const profiles = Object.freeze([{ - claimsRealHostParity: false, - evidence: 'simulated', - id: 'portable', - label: 'Portable MCP Apps', - version: 'agent-bundle:mcp-apps:2026-01-26', -}] satisfies readonly RuntimeProfileOption[]); - -const bootstrap = (): RuntimeBootstrap => Object.freeze({ - history: Object.freeze([run('initial')]), - kind: 'available' as const, - providerSessionId: vector.providerSessionId, - status, - surfaces: Object.freeze([surface]), -}); - -const generationFailedEvent = Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ - providerSessionId: vector.providerSessionId, - runtimeGenerationId: vector.runtimeGenerationId, - type: 'runtime.generation.failed' as const, - }), - sequence: 1, - type: 'runtime.event' as const, -}) satisfies ProjectEventMessage; - -const client = (resetState: () => Promise): RuntimePlaygroundClient => ({ - bootstrap: async () => bootstrap(), - createRun: async (_request: DevRuntimeInvocationRequest) => run('created'), - readRun: async (id) => run(id), - readRunDocument: async () => [], - readRunFlight: async () => new Blob(['flight'], { type: 'application/octet-stream' }), - replayRun: async (_request: DevRuntimeReplayRequest) => run('replayed'), - resetState: async (_request: DevRuntimeStateResetRequest) => resetState(), -}); - -test('mounts Runtime controls in a supported browser and fences reset interactions through its correlated success', { timeout: 15_000 }, async () => { - let resetAttempts = 0; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap(), - client: client(async () => { - resetAttempts += 1; - if (resetAttempts === 1) throw new Error('The provider rejected this reset.'); - return Object.freeze({ stateStoreId: 'browser-state', stateVersion: 2 }); - }), - profiles, - }); - try { - await render(); - await expect.element(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible(); - await expect.element(page.locator('[data-runtime-run-id="initial"]')).toBeVisible(); - controller.dispatch({ event: generationFailedEvent, type: 'event.received' }); - await expect.element(page.locator('.runtime-announcement[role="alert"]')).toHaveText('Runtime generation failed. The last good result remains available.'); - await expect.element(page.getByRole('tab', { name: 'Result', exact: true })).toHaveAttribute('aria-selected', 'true'); - await expect.element(page.getByLabel('Runtime output stage')).toContainText('Agent-visible output'); - - await page.getByRole('radio', { name: 'Raw JSON' }).click(); - const raw = page.locator('#runtime-input-raw'); - await raw.fill('{"city":'); - await expect.element(page.locator('#runtime-input-raw-error')).toHaveText('Draft JSON is invalid. Repair the raw input before running.'); - await raw.fill('{"city":"Paris"}'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect.element(page.getByRole('dialog')).toBeVisible(); - await expect.element(page.getByRole('button', { name: 'Run', exact: true })).toBeDisabled(); - await expect.element(raw).toBeDisabled(); - await expect.element(page.getByRole('button', { name: 'Replay exact' })).toBeDisabled(); - await page.getByRole('button', { name: 'Cancel' }).click(); - await expect.element(page.getByRole('dialog')).not.toBeVisible(); - - await page.getByRole('button', { name: 'Reset fixture state' }).click(); - await expect.element(page.getByRole('dialog')).toHaveText(/State store.*browser-state/su); - await expect.element(page.getByRole('button', { name: 'Reset fixture state' })).toBeDisabled(); - await expect.element(page.getByLabel('Runtime surface')).toBeDisabled(); - await page.getByRole('button', { name: 'Confirm' }).click(); - const failure = page.locator('.runtime-request-error'); - await expect.element(failure).toHaveText('The provider rejected this reset.'); - await expect.element(failure).toBeFocused(); - await expect.element(page.locator('.runtime-status')).not.toBeFocused(); - await expect.element(page.getByRole('button', { name: 'Run', exact: true })).toBeEnabled(); - controller.dispatch({ tab: 'tree', type: 'selection.tab' }); - await expect.element(failure).toBeFocused(); - - await page.getByRole('button', { name: 'Reset fixture state' }).click(); - await page.getByRole('button', { name: 'Confirm' }).click(); - await expect.element(failure).not.toBeVisible(); - await expect.element(page.getByText('2', { exact: true })).toBeVisible(); - expect(controller.model.resetCompletion?.state).toEqual({ stateStoreId: 'browser-state', stateVersion: 2 }); - expect(controller.model.activeEffect).toBeUndefined(); - expect((document.activeElement as HTMLElement | null)?.className).toBe('runtime-status'); - await expect.element(page.locator('.runtime-status')).toBeFocused(); - controller.dispatch({ tab: 'diagnostics', type: 'selection.tab' }); - await expect.element(page.locator('.runtime-status')).toBeFocused(); - } finally { - controller.close(); - } -}); - -test('downloads the selected Flight payload through the authenticated client and toggles trace span details', { timeout: 15_000 }, async () => { - const documentRequests: string[] = []; - const flightRequests: string[] = []; - let rejectDownload = true; - const controller = createRuntimePlaygroundController({ - bootstrap: Object.freeze({ - history: Object.freeze([evidenceRun('evidence')]), - kind: 'available' as const, - providerSessionId: vector.providerSessionId, - status, - surfaces: Object.freeze([surface]), - }), - client: { - ...client(async () => Object.freeze({ stateStoreId: 'browser-state', stateVersion: 2 })), - readRunDocument: async (id) => { - documentRequests.push(id); - const document = { - root: { - children: [{ kind: 'markdown' as const, text: '# Browser document' }], - kind: 'result' as const, - }, - status: 'success' as const, - version: 1 as const, - }; - return [ - { document, sequence: 0, type: 'shell' as const }, - { completed: 1, message: 'Rendered', sequence: 1, total: 1, type: 'progress' as const }, - { document, sequence: 2, type: 'complete' as const }, - ] satisfies readonly AgentRenderEvent[]; - }, - readRunFlight: async (id) => { - flightRequests.push(id); - if (rejectDownload) throw new Error('The Flight payload is unavailable.'); - return new Blob(['FL'], { type: 'application/octet-stream' }); - }, - }, - profiles, - }); - try { - await render(); - await expect.element(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible(); - - await page.getByRole('tab', { name: 'Flight', exact: true }).click(); - const download = page.getByRole('button', { name: 'Download Flight payload' }); - await expect.element(download).toBeVisible(); - await download.click(); - await expect.element(page.locator('.runtime-request-error[role="alert"]')).toHaveText('The Flight payload is unavailable.'); - rejectDownload = false; - await download.click(); - await expect.element(page.locator('.runtime-request-error[role="alert"]')).not.toBeVisible(); - expect(flightRequests).toEqual(['evidence', 'evidence']); - - await page.getByRole('tab', { name: 'Document', exact: true }).click(); - await expect.element(page.getByRole('heading', { name: 'Browser document' })).toBeVisible(); - await expect.element(page.getByLabel('Agent Document')).toContainText('Rendered · 1 / 1'); - await expect.element(page.getByLabel('Agent Document')).toContainText('Version 1 · success'); - expect(documentRequests).toEqual(['evidence']); - - await page.getByRole('tab', { name: 'Diagnostics', exact: true }).click(); - const toggle = page.getByRole('button', { name: 'Show span details' }); - await expect.element(toggle).toBeVisible(); - await expect.element(page.getByLabel('Runtime render trace')).not.toContainText('"step": "render"'); - await toggle.click(); - await expect.element(page.getByRole('button', { name: 'Hide span details' })).toHaveAttribute('aria-expanded', 'true'); - await expect.element(page.getByLabel('Runtime render trace')).toContainText('"step": "render"'); - await page.getByRole('button', { name: 'Hide span details' }).click(); - await expect.element(page.getByLabel('Runtime render trace')).not.toContainText('"step": "render"'); - } finally { - controller.close(); - } -}); diff --git a/packages/workbench/tests/runtime-playground.e2e.test.ts b/packages/workbench/tests/runtime-playground.e2e.test.ts index b7987d13d..f5a3624ba 100644 --- a/packages/workbench/tests/runtime-playground.e2e.test.ts +++ b/packages/workbench/tests/runtime-playground.e2e.test.ts @@ -165,6 +165,32 @@ e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { await expect(page.getByRole('tabpanel')) .toContainText('Stored Flight could not be decoded as an Agent Document.', { timeout: browserTimeout }); + // The hook run's stored Flight is downloadable from the Flight tab. The + // client rejects any response that is not `application/octet-stream` + // and surfaces the rejection as an alert; the real route then clears the + // alert and hands the viewer a `runtime-run-.flight.bin` download. + await history.nth(1).getByRole('button').first().click(); + await page.getByRole('tab', { name: 'Flight', exact: true }).click(); + const download = page.getByRole('button', { name: 'Download Flight payload' }); + await expect(download).toBeVisible({ timeout: browserTimeout }); + const flightAlert = page.locator('.runtime-request-error[role="alert"]'); + const flightRoute = '**/api/runtime/runs/*/flight'; + await page.route(flightRoute, (route) => route.fulfill({ body: 'nope', contentType: 'text/plain', status: 200 })); + await download.click(); + await expect(flightAlert).toHaveText('Runtime Flight response is not valid.', { timeout: browserTimeout }); + await page.unroute(flightRoute); + const downloadEvent = page.waitForEvent('download'); + await download.click(); + const downloaded = await downloadEvent; + expect(downloaded.suggestedFilename()).toMatch(/^runtime-run-.+\.flight\.bin$/u); + await expect(flightAlert).toHaveCount(0, { timeout: browserTimeout }); + + // Real trace spans carry no `details`, so the Diagnostics tab renders the + // render phases without a span-details disclosure. + await page.getByRole('tab', { name: 'Diagnostics', exact: true }).click(); + await expect(page.getByLabel('Runtime render trace')).toContainText('normalize', { timeout: browserTimeout }); + await expect(page.getByRole('button', { name: /span details/u })).toHaveCount(0); + const reset = page.getByRole('button', { name: 'Reset fixture state' }); const stateVersionBeforeReset = await identity.getAttribute('data-runtime-state-version'); await reset.click(); @@ -185,14 +211,41 @@ e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { await expect(confirmation).toBeHidden(); await expect(reset).toBeFocused({ timeout: browserTimeout }); expect(resetRequests).toEqual([]); + const expectedResetRequest = { + expectedGenerationId: runtimeIdentity.activeVector.runtimeGenerationId, + stateStoreId: runtimeIdentity.activeVector.stateStoreId, + }; + + // A rejected reset surfaces as a focused alert, hands the controls back, + // and leaves the state untouched; the next attempt goes through. The + // rejection is the client's own: an invalid state wrapper is refused + // before any provider identity is trusted. + const resetRoute = '**/api/runtime/state/reset'; + await page.route(resetRoute, (route) => route.fulfill({ body: '{}', contentType: 'application/json', status: 200 })); await reset.click(); await expect(confirmation).toContainText('State store'); await confirmation.getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(() => resetRequests).toHaveLength(1); - expect(resetRequests).toEqual([{ - expectedGenerationId: runtimeIdentity.activeVector.runtimeGenerationId, - stateStoreId: runtimeIdentity.activeVector.stateStoreId, - }]); + const resetFailure = page.locator('.runtime-request-error[role="alert"]'); + await expect(resetFailure).toHaveText('Runtime route returned an invalid state wrapper.', { timeout: browserTimeout }); + await expect(resetFailure).toBeFocused({ timeout: browserTimeout }); + await expect(page.locator('.runtime-status')).not.toBeFocused(); + await expect(confirmation).toBeHidden(); + await expect(run).toBeEnabled(); + await expect(reset).toBeEnabled(); + await expect(page.getByLabel('Runtime surface')).toBeEnabled(); + await page.getByRole('tab', { name: 'Tree', exact: true }).click(); + await expect(resetFailure).toBeVisible(); + await expect(page.locator('.runtime-status')).not.toBeFocused(); + await expect(identity).toHaveAttribute('data-runtime-state-version', String(stateVersionBeforeReset)); + await page.unroute(resetRoute); + await expect.poll(() => resetRequests).toEqual([expectedResetRequest]); + + await reset.click(); + await expect(confirmation).toContainText('State store'); + await confirmation.getByRole('button', { name: 'Confirm' }).click(); + await expect.poll(() => resetRequests).toHaveLength(2); + expect(resetRequests).toEqual([expectedResetRequest, expectedResetRequest]); + await expect(resetFailure).toHaveCount(0, { timeout: browserTimeout }); await expect.poll(async () => identity.getAttribute('data-runtime-state-version'), { timeout: browserTimeout }).not.toBe(stateVersionBeforeReset); await expect(page.locator('.runtime-status')).toBeFocused({ timeout: browserTimeout }); diff --git a/packages/workbench/tests/runtime-playground.test.ts b/packages/workbench/tests/runtime-playground.test.ts index f225acdbf..ab09a2cf6 100644 --- a/packages/workbench/tests/runtime-playground.test.ts +++ b/packages/workbench/tests/runtime-playground.test.ts @@ -551,6 +551,44 @@ it('renders previous-provider last-good output separately from session-only runt expect(markup).toContain('"city": "London"'); }); +it('toggles span details for traced spans that carry them and renders no toggle otherwise', async () => { + const traced = run('01', { + result: Object.freeze({ + agentVisible: Object.freeze({ city: 'London' }), + state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), + trace: Object.freeze([ + Object.freeze({ details: Object.freeze({ step: 'render' }), id: 'render', phase: 'render', startedAt: '2026-08-15T12:01:00.000Z', status: 'succeeded' as const }), + Object.freeze({ id: 'flight', parentId: 'render', phase: 'flight', startedAt: '2026-08-15T12:01:00.500Z', status: 'succeeded' as const }), + ]), + tree: Object.freeze([]), + }), + }); + const controller = createRuntimePlaygroundController({ bootstrap: bootstrap({ history: Object.freeze([traced]) }), client: clientFor(), profiles }); + controller.dispatch({ tab: 'diagnostics', type: 'selection.tab' }); + const toggles = (markup: string): ReadonlyArray> => + (markup.match(/]*>(?:Show|Hide) span details<\/button>/gu) ?? []).map((button) => ({ + expanded: /aria-expanded="([^"]*)"/u.exec(button)?.[1], + label: button.includes('Hide') ? 'Hide span details' : 'Show span details', + })); + + const collapsed = await renderWhenReady(createElement(RuntimePlayground, { controller })); + expect(toggles(collapsed)).toEqual([{ expanded: 'false', label: 'Show span details' }]); + expect(collapsed).toContain('
  • flight'); + expect(collapsed).not.toContain('"step": "render"'); + + controller.dispatch({ spanId: 'render', type: 'trace.toggle' }); + const expanded = await renderWhenReady(createElement(RuntimePlayground, { controller })); + expect(controller.model.expandedTraceSpanIds).toEqual(['render']); + expect(toggles(expanded)).toEqual([{ expanded: 'true', label: 'Hide span details' }]); + expect(expanded).toContain('"step": "render"'); + + controller.dispatch({ spanId: 'render', type: 'trace.toggle' }); + const recollapsed = await renderWhenReady(createElement(RuntimePlayground, { controller })); + expect(controller.model.expandedTraceSpanIds).toEqual([]); + expect(toggles(recollapsed)).toEqual([{ expanded: 'false', label: 'Show span details' }]); + expect(recollapsed).not.toContain('"step": "render"'); +}); + it('initializes all provider history items without truncating the server-owned fifty item window', () => { const history = Object.freeze(Array.from({ length: 50 }, (_, index) => run(String(50 - index).padStart(2, '0')))); const controller = createRuntimePlaygroundController({ bootstrap: bootstrap({ history }), client: clientFor(), profiles }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6efd6094..23682ff09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,12 +45,6 @@ importers: '@rstest/adapter-rslib': specifier: 0.11.10 version: 0.11.10(@rslib/core@0.23.2(typescript@7.0.2))(@rstest/core@0.11.10)(typescript@7.0.2) - '@rstest/browser': - specifier: 0.11.10 - version: 0.11.10(@rstest/core@0.11.10)(playwright@1.62.1) - '@rstest/browser-react': - specifier: 0.11.10 - version: 0.11.10(@rstest/core@0.11.10)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rstest/core': specifier: 0.11.10 version: 0.11.10 @@ -1363,14 +1357,6 @@ packages: typescript: optional: true - '@rstest/browser-react@0.11.10': - resolution: {integrity: sha512-LFMjeUfMmfM2HnEk/5YHGXNXLMQCwKdA0fHcNzQztYYeTVb9FB0SPwv6FrqduNXZUAyNyHryxS2TO4jDYaYWvg==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - '@rstest/core': ^0.11.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - '@rstest/browser@0.11.10': resolution: {integrity: sha512-Ic9QD8uA2aDaUaFeDgXZoEwEJerV4pwcHo2113hS/UfgpdIgi7n1ygG/tsrRUCzHPDPL3JrN+YYMcntDACAxow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4269,12 +4255,6 @@ snapshots: optionalDependencies: typescript: 7.0.2 - '@rstest/browser-react@0.11.10(@rstest/core@0.11.10)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@rstest/core': 0.11.10 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - '@rstest/browser@0.11.10(@rstest/core@0.11.10)(playwright@1.62.1)': dependencies: '@jridgewell/trace-mapping': 0.3.31 diff --git a/rstest.config.ts b/rstest.config.ts index 90a4f494b..789d74f94 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -6,14 +6,13 @@ import { projectionTestFiles, routeUnitTestFiles, templateTestFiles, + workspaceTestFileGlob, } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; export default defineConfig({ extends: withAgentBundleRslibConfig(), - include: [ - 'packages/**/tests/**/*.test.ts', - ], + include: [workspaceTestFileGlob], exclude: [ ...fixtureProjectTestFiles, ...mcpConformanceTestFiles, diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index c6b3a3db8..d1baeda6f 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -1,3 +1,12 @@ +/** + * What the workspace pools collect before the lists below subtract from it: + * `rstest.unit.config.ts` runs this minus every list; `rstest.config.ts` (the + * whole-workspace run) minus the lists that need their own process shape. A + * test file this glob does not match (another extension, another directory) + * runs only if a list names it — rstest-pool-lists.test.ts enforces that. + */ +export const workspaceTestFileGlob = 'packages/**/tests/**/*.test.ts'; + /** * Test files that run real builds (Rslib/Rsbuild), spawn child processes, or * drive a browser. They run through rstest.integration.config.ts: per-test diff --git a/rstest.projection.config.ts b/rstest.projection.config.ts index 76d47d9c6..aa0a2efbb 100644 --- a/rstest.projection.config.ts +++ b/rstest.projection.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { defineConfig } from '@rstest/core'; import { agentBundleRstest } from './packages/agent-bundle/src/rstest/index.ts'; +import { rstestHygiene } from './rstest.rslib.ts'; /** * The repository's in-process projection pool (#103 stage 2): the @@ -13,8 +14,13 @@ import { agentBundleRstest } from './packages/agent-bundle/src/rstest/index.ts'; * * Neither level opens a process. The `packed-stdio` level lives in the packed * pool (`pnpm test:packed`), which owns the run's single build and pack. + * Per-test restoration comes from the workspace's shared policy, as in the + * route-unit pool. */ -export default defineConfig(await agentBundleRstest({ - include: ['packages/agent-bundle/tests/projection/**/*.test.ts'], - root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), -})); +export default defineConfig({ + ...(await agentBundleRstest({ + include: ['packages/agent-bundle/tests/projection/**/*.test.ts'], + root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), + })), + ...rstestHygiene, +}); diff --git a/rstest.route-unit.config.ts b/rstest.route-unit.config.ts index 9bcf886ca..734f011fe 100644 --- a/rstest.route-unit.config.ts +++ b/rstest.route-unit.config.ts @@ -3,15 +3,20 @@ import { resolve } from 'node:path'; import { defineConfig } from '@rstest/core'; import { agentBundleRstest } from './packages/agent-bundle/src/rstest/index.ts'; +import { rstestHygiene } from './rstest.rslib.ts'; /** * The repository's own route-unit pool, built from the consumer configuration * helper so the shipped surface is what CI exercises. The route-unit level * needs the `react-server` Node condition, which is a pool-level process * flag — that is why it is a separate run from `rstest.unit.config.ts` and not - * a project inside it. + * a project inside it. The shipped helper carries no per-test restoration + * policy (that is the consumer's call), so the workspace pool adds its own. */ -export default defineConfig(await agentBundleRstest({ - include: ['packages/agent-bundle/tests/route-unit/**/*.test.ts'], - root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), -})); +export default defineConfig({ + ...(await agentBundleRstest({ + include: ['packages/agent-bundle/tests/route-unit/**/*.test.ts'], + root: resolve(import.meta.dirname, 'packages/agent-bundle/fixtures/route-harness'), + })), + ...rstestHygiene, +}); diff --git a/rstest.rslib.ts b/rstest.rslib.ts index 70f095ec4..e8da54a3b 100644 --- a/rstest.rslib.ts +++ b/rstest.rslib.ts @@ -1,18 +1,88 @@ import { resolve } from 'node:path'; import { withRslibConfig } from '@rstest/adapter-rslib'; +import type { ExtendConfig, ExtendConfigFn } from '@rstest/core'; const workspaceRoot = import.meta.dirname; const packageRoot = resolve(workspaceRoot, 'packages/agent-bundle'); -export const withAgentBundleRslibConfig = () => withRslibConfig({ - cwd: packageRoot, - modifyLibConfig: (config) => ({ - ...config, - root: workspaceRoot, - source: { - ...config.source, - tsconfigPath: resolve(workspaceRoot, 'tsconfig.json'), +/** + * Plugins `packages/agent-bundle/rslib.config.ts` registers for publishing + * the package, not for compiling its modules. The adapter copies the lib + * config's `plugins` into every pool verbatim, so without this filter both + * would run inside each test bundle: + * + * - `plugin-publint` (rsbuild-plugin-publint, `throwOn: 'warning'`) audits + * the manifest at `api.context.rootPath` in `onAfterBuild` — under the + * pools that would be the workspace root's private `package.json`, and a + * test build has no manifest to gate. Rstest 0.11 never drives + * `onAfterBuild` (DEBUG=rstest shows no publint output), so today the + * registration is inert; it stays out so a runner change cannot arm it. + * - `agent-bundle:esm-node-globals` prepends the `__filename`/`__dirname` + * shim to emitted chunks that inline the TypeScript 5 parser. Its + * `processAssets` scan did run over every test chunk. Pools leave + * dependencies external, and Rstest supplies the real `__dirname` and + * `__filename` of each test module itself. + * + * Verify the names against the plugin objects, not the package names: + * `pluginPublint().name` is `plugin-publint`. + */ +const publishOnlyPlugins: ReadonlySet = new Set(['plugin-publint', 'agent-bundle:esm-node-globals']); + +/** The `name` of an Rsbuild plugin entry; nested arrays, promises, and falsy entries have none. */ +const pluginName = (plugin: unknown): string | undefined => ( + typeof plugin === 'object' && plugin !== null && 'name' in plugin && typeof plugin.name === 'string' + ? plugin.name + : undefined +); + +/** + * Per-test restoration shared by every pool. The unit pool runs with + * `isolate: false`, so a mock, `rs.stubEnv`, or `rs.stubGlobal` a test leaves + * behind is the next file's problem; restoring before each test makes file + * order irrelevant. Rstest dispatches `restoreMocks` ahead of `clearMocks` + * (its `mockRestore` is `mockReset` plus the original implementation, so + * calls are cleared too); `clearMocks` stays listed as the floor should + * `restoreMocks` ever be relaxed. + */ +export const rstestHygiene = { + clearMocks: true, + restoreMocks: true, + unstubEnvs: true, + unstubGlobals: true, +} as const satisfies ExtendConfig; + +/** + * The shared pool configuration: the package's Rslib build config, reduced to + * what compiling tests needs, plus `rstestHygiene`. + * + * Kept from the package build: `source.define` — `__AGENT_BUNDLE_VERSION__` + * in src/cli.ts is a compile-time identifier and resolves here exactly as in + * the published build — and `source.tsconfigPath`, repointed at the workspace + * tsconfig so test files resolve beside the sources. Dropped: the publish-only + * plugins above and `tools.rspack`, whose `ignoreWarnings` entry and + * `node.__dirname = false` exist for the inlined TypeScript parser (external + * in pools; Rstest sets its own `node` options). + */ +export const withAgentBundleRslibConfig = (): ExtendConfigFn => { + const rslib = withRslibConfig({ + cwd: packageRoot, + modifyLibConfig: ({ plugins, tools, ...config }) => { + const { rspack: _publishOnlyRspack, ...testTools } = tools ?? {}; + return { + ...config, + root: workspaceRoot, + plugins: plugins?.filter((plugin) => { + const name = pluginName(plugin); + return name === undefined || !publishOnlyPlugins.has(name); + }), + source: { + ...config.source, + tsconfigPath: resolve(workspaceRoot, 'tsconfig.json'), + }, + tools: testTools, + }; }, - }), -}); + }); + return async (userConfig) => ({ ...(await rslib(userConfig)), ...rstestHygiene }); +}; diff --git a/rstest.runtime-playground.browser.config.ts b/rstest.runtime-playground.browser.config.ts deleted file mode 100644 index c066ce0c4..000000000 --- a/rstest.runtime-playground.browser.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { realpathSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { pluginReact } from '@rsbuild/plugin-react'; -import { withRslibConfig } from '@rstest/adapter-rslib'; -import { defineConfig } from '@rstest/core'; - -const browserReactRoot = realpathSync(resolve('node_modules/react')); -const browserReactDomRoot = realpathSync(resolve('node_modules/react-dom')); - -export default defineConfig({ - browser: { - enabled: true, - headless: true, - provider: 'playwright', - providerOptions: { launch: { channel: 'chrome' } }, - viewport: { height: 900, width: 1440 }, - }, - extends: withRslibConfig(), - include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], - plugins: [pluginReact()], - setupFiles: ['./rstest.setup.browser.ts'], - resolve: { - alias: { - react: browserReactRoot, - 'react-dom': browserReactDomRoot, - }, - }, - tools: { - rspack: { - resolve: { - extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] }, - }, - }, - }, -}); diff --git a/rstest.runtime-playground.config.ts b/rstest.runtime-playground.config.ts deleted file mode 100644 index bccb49df8..000000000 --- a/rstest.runtime-playground.config.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { realpathSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { pluginReact } from '@rsbuild/plugin-react'; -import { withRslibConfig } from '@rstest/adapter-rslib'; -import { defineConfig, defineInlineProject } from '@rstest/core'; - -const browserReactRoot = realpathSync(resolve('node_modules/react')); -const browserReactDomRoot = realpathSync(resolve('node_modules/react-dom')); - -export default defineConfig({ - coverage: { - enabled: true, - include: ['packages/workbench/src/runtime-{client,model,playground}.{ts,tsx}'], - provider: 'v8', - reporters: ['text', 'json'], - thresholds: { branches: 85, functions: 90, lines: 90, statements: 90 }, - }, - projects: [ - defineInlineProject({ - extends: withRslibConfig(), - include: [ - 'packages/workbench/tests/runtime-client.test.ts', - 'packages/workbench/tests/runtime-contract-compile.test.ts', - 'packages/workbench/tests/runtime-model.test.ts', - 'packages/workbench/tests/runtime-playground.test.ts', - ], - name: 'runtime-node', - setupFiles: ['./rstest.setup.ts'], - testEnvironment: 'node', - }), - defineInlineProject({ - browser: { - enabled: true, - headless: true, - provider: 'playwright', - providerOptions: { launch: { channel: 'chrome' } }, - viewport: { height: 900, width: 1440 }, - }, - extends: withRslibConfig(), - include: ['packages/workbench/tests/runtime-playground.browser.test.tsx'], - name: 'runtime-browser', - setupFiles: ['./rstest.setup.browser.ts'], - plugins: [pluginReact()], - resolve: { - alias: { - react: browserReactRoot, - 'react-dom': browserReactDomRoot, - }, - }, - tools: { - rspack: { - resolve: { - extensionAlias: { '.js': ['.js', '.ts', '.tsx'], '.jsx': ['.jsx', '.tsx'] }, - }, - }, - }, - }), - ], -}); diff --git a/rstest.setup.browser.ts b/rstest.setup.browser.ts deleted file mode 100644 index 064dae7a4..000000000 --- a/rstest.setup.browser.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Browser pools bundle setup files into the page bundle, where node: builtins -// are an unhandled scheme. Worker isolation (rstest.setup.ts) redirects -// TMPDIR/XDG caches for Node test processes and has no browser equivalent, so -// browser projects load this empty setup instead. -export {}; diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index 763ec30b4..e966da3a4 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -10,15 +10,14 @@ import { projectionTestFiles, routeUnitTestFiles, templateTestFiles, + workspaceTestFileGlob, } from './rstest.integration-tests.ts'; import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** Build-free, process-free tests only; safe on parallel workers. `pnpm test` runs this before the integration config. */ export default defineConfig({ extends: withAgentBundleRslibConfig(), - include: [ - 'packages/**/tests/**/*.test.ts', - ], + include: [workspaceTestFileGlob], exclude: [ ...fixtureProjectTestFiles, ...integrationTestFiles,