diff --git a/.changeset/workbench-atom-phase2.md b/.changeset/workbench-atom-phase2.md new file mode 100644 index 000000000..6d9f14e73 --- /dev/null +++ b/.changeset/workbench-atom-phase2.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": patch +--- + +The Workbench route input editor migrated its coordinated local state to +`@effect/atom-react` atoms keyed by manifest digest and compiled route id, +preserving typed and raw validation behavior while releasing state on unmount. diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 76339176a..eeee4d497 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -182,7 +182,8 @@ static assets. Exact-pin `effect` + `@effect/atom-react` (synchronized with the repo's effect pin, currently `4.0.0-rc.112`) are allowed there, but only in dedicated -browser-state modules (the first is `src/runtime/agent-document-atoms.ts`). +browser-state modules (`src/runtime/agent-document-atoms.ts` and +`src/routes/route-editor-atoms.ts`). Atoms live in `effect/unstable/reactivity`; React bindings come from `@effect/atom-react`. @@ -227,7 +228,7 @@ The #99 notice ledger also adopts none: it needs only stable `Effect` and | Module | Adopted in | Re-verify | | --- | --- | --- | -| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | +| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | ## Language service diff --git a/packages/workbench/src/routes/route-editor-atoms.ts b/packages/workbench/src/routes/route-editor-atoms.ts new file mode 100644 index 000000000..ab21c69bf --- /dev/null +++ b/packages/workbench/src/routes/route-editor-atoms.ts @@ -0,0 +1,13 @@ +import { Atom } from 'effect/unstable/reactivity'; + +import type { RouteEditorState } from './routes-model.ts'; + +/** + * Digest plus compiled route id isolates editor state across manifests. The + * undefined sentinel lets provider-less SSR derive schema defaults locally. + */ +export const routeEditorKey = (digest: string, routeId: string): string => `${digest}\u0000${routeId}`; + +export const routeEditorStateAtom = Atom.family( + (key: string) => Atom.make(undefined), +); diff --git a/packages/workbench/src/routes/routes-model.ts b/packages/workbench/src/routes/routes-model.ts index df48a9fd1..21d2bf4c3 100644 --- a/packages/workbench/src/routes/routes-model.ts +++ b/packages/workbench/src/routes/routes-model.ts @@ -96,6 +96,16 @@ export interface RawRouteInputValidation { readonly error?: string; } +export interface RouteEditorState { + readonly attempted: boolean; + readonly arguments?: RouteInputArguments; + readonly argv?: string; + readonly draft: RouteInputDraft; + readonly errors: Readonly>; + readonly raw: string; + readonly rawError?: string; +} + export interface McpToolPrefill { readonly arguments: RouteInputArguments; readonly serverName: string; @@ -243,6 +253,13 @@ export const createRouteInputDraft = (schema: RouteInputSchema): RouteInputDraft )); }; +export const initialRouteEditorState = (schema?: RouteInputSchema): RouteEditorState => Object.freeze({ + attempted: false, + draft: schema === undefined ? Object.freeze({}) : createRouteInputDraft(schema), + errors: Object.freeze({}), + raw: '{}', +}); + export const routeInputLabel = (key: string): string => { const words = key .replace(/([a-z0-9])([A-Z])/gu, '$1 $2') @@ -411,6 +428,77 @@ export const cliCommandInvocation = ( return argv.join(' '); }; +const routeEditorArgv = ( + command: RouteManifestCliCommand | undefined, + argumentsValue: RouteInputArguments | undefined, +): string | undefined => argumentsValue === undefined || command === undefined + ? undefined + : cliCommandInvocation(command, argumentsValue); + +export const setRouteEditorDraftValue = ( + state: RouteEditorState, + schema: RouteInputSchema | undefined, + command: RouteManifestCliCommand | undefined, + key: string, + value: RouteInputDraftValue | undefined, +): RouteEditorState => { + const entries = { ...state.draft }; + if (value === undefined) { + delete entries[key]; + } else { + entries[key] = value; + } + const draft = Object.freeze(entries); + if (!state.attempted || schema === undefined) return Object.freeze({ ...state, draft }); + const validated = validateRouteInput(schema, draft); + return Object.freeze({ + ...state, + arguments: validated.arguments, + argv: routeEditorArgv(command, validated.arguments), + draft, + errors: Object.freeze({ ...validated.errors }), + }); +}; + +export const setRouteEditorRaw = ( + state: RouteEditorState, + raw: string, +): RouteEditorState => { + if (!state.attempted) return Object.freeze({ ...state, raw }); + const validated = validateRawRouteInput(raw); + return Object.freeze({ + ...state, + arguments: validated.arguments, + raw, + rawError: validated.error, + }); +}; + +export const validateRouteEditor = ( + state: RouteEditorState, + schema: RouteInputSchema | undefined, + command: RouteManifestCliCommand | undefined, +): RouteEditorState => { + if (schema === undefined) { + const validated = validateRawRouteInput(state.raw); + return Object.freeze({ + ...state, + arguments: validated.arguments, + argv: routeEditorArgv(command, validated.arguments), + attempted: true, + rawError: validated.error, + }); + } + const validated = validateRouteInput(schema, state.draft); + return Object.freeze({ + ...state, + arguments: validated.arguments, + argv: routeEditorArgv(command, validated.arguments), + attempted: true, + errors: Object.freeze({ ...validated.errors }), + }); +}; + export const mcpToolPrefillFor = ( group: RouteCatalogGroup, entry: RouteCatalogEntry, diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index 537416df6..a2b5aaf2e 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -1,21 +1,21 @@ -import React, { useState } from 'react'; +import { useAtom } from '@effect/atom-react'; +import React from 'react'; import type { RouteInputPropertySchema } from '../../../agent-bundle/src/contracts/routes.ts'; +import { routeEditorKey, routeEditorStateAtom } from './route-editor-atoms.ts'; import { - cliCommandInvocation, cliCommandUsage, - createRouteInputDraft, + initialRouteEditorState, mcpToolPrefillFor, routeInputLabel, - validateRawRouteInput, - validateRouteInput, + setRouteEditorDraftValue, + setRouteEditorRaw, + validateRouteEditor, type McpToolPrefill, type RouteCatalog, type RouteCatalogEntry, type RouteCatalogGroup, type RouteCatalogServer, - type RouteInputArguments, - type RouteInputDraft, type RouteInputDraftValue, } from './routes-model.ts'; import './routes-page.css'; @@ -113,57 +113,38 @@ const scalarControl = ( } }; -const RouteInputEditor = ({ entry, group, onOpenMcp }: { +const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { + readonly digest: string; readonly entry: RouteCatalogEntry; readonly group: RouteCatalogGroup; readonly onOpenMcp?: (prefill: McpToolPrefill) => void; }) => { const schema = entry.inputSchema; - const [draft, setDraft] = useState(() => schema === undefined ? Object.freeze({}) : createRouteInputDraft(schema)); - const [raw, setRaw] = useState('{}'); - const [errors, setErrors] = useState>>({}); - const [rawError, setRawError] = useState(); - const [attempted, setAttempted] = useState(false); - const [argumentsValue, setArgumentsValue] = useState(); - const [argv, setArgv] = useState(); - - const commitValidation = (next: RouteInputDraft): void => { - if (schema === undefined || !attempted) return; - const validated = validateRouteInput(schema, next); - setErrors(validated.errors); - setArgumentsValue(validated.arguments); - setArgv(validated.arguments === undefined || entry.command === undefined - ? undefined - : cliCommandInvocation(entry.command, validated.arguments)); - }; + const [stored, setStored] = useAtom(routeEditorStateAtom(routeEditorKey(digest, entry.id))); + const state = stored ?? initialRouteEditorState(schema); + const { + arguments: argumentsValue, + argv, + draft, + errors, + raw, + rawError, + } = state; const setValue = (key: string, value: RouteInputDraftValue | undefined): void => { - const entries = { ...draft }; - if (value === undefined) { - delete entries[key]; - } else { - entries[key] = value; - } - const next = Object.freeze(entries); - setDraft(next); - commitValidation(next); + setStored((current) => setRouteEditorDraftValue( + current ?? initialRouteEditorState(schema), + schema, + entry.command, + key, + value, + )); }; const validate = (): void => { - setAttempted(true); - if (schema === undefined) { - const validated = validateRawRouteInput(raw); - setRawError(validated.error); - setArgumentsValue(validated.arguments); - setArgv(validated.arguments === undefined || entry.command === undefined - ? undefined - : cliCommandInvocation(entry.command, validated.arguments)); - return; - } - const validated = validateRouteInput(schema, draft); - setErrors(validated.errors); - setArgumentsValue(validated.arguments); - setArgv(validated.arguments === undefined || entry.command === undefined - ? undefined - : cliCommandInvocation(entry.command, validated.arguments)); + setStored((current) => validateRouteEditor( + current ?? initialRouteEditorState(schema), + schema, + entry.command, + )); }; const openMcp = (): void => { if (argumentsValue === undefined || onOpenMcp === undefined) return; @@ -180,12 +161,10 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: { id={editorId(entry.id, 'raw')} onChange={(event) => { const next = event.currentTarget.value; - setRaw(next); - if (attempted) { - const validated = validateRawRouteInput(next); - setRawError(validated.error); - setArgumentsValue(validated.arguments); - } + setStored((current) => setRouteEditorRaw( + current ?? initialRouteEditorState(schema), + next, + )); }} rows={4} value={raw} @@ -251,7 +230,8 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: { const commandSummary = (entry: RouteCatalogEntry): string | undefined => entry.command === undefined ? undefined : cliCommandUsage(entry.command); -const RouteGroup = ({ group, onOpenMcp }: { +const RouteGroup = ({ digest, group, onOpenMcp }: { + readonly digest: string; readonly group: RouteCatalogGroup; readonly onOpenMcp?: (prefill: McpToolPrefill) => void; }) =>
{entry.source}{entry.provenance}

{configSummary(entry)}

- + )} @@ -307,6 +287,7 @@ export const RoutesPage = ({ catalog, onOpenMcp }: RoutesPageProps) =>
server.routeCount === 0) .map((server) => )} {catalog.groups.map((group) => path.endsWith('.css') + ? 'text/css' + : path.endsWith('.js') + ? 'text/javascript' + : 'text/html'; + +const startStaticServer = async (root: string) => { + const server = createServer((request, response) => { + const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; + const file = pathname === '/' ? 'route-editor-atoms-fixture.html' : pathname.slice(1); + const path = normalize(join(root, file)); + if (relative(root, path).startsWith('..')) { + response.writeHead(404).end(); + return; + } + void readFile(path).then((body) => response.writeHead(200, { 'content-type': contentType(path) }).end(body), () => response.writeHead(404).end()); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('Route editor atom fixture did not expose a TCP address.'); + return { server, url: `http://127.0.0.1:${address.port}` }; +}; + +const fixtureSource = (root: string): string => ` + import { RegistryProvider } from '@effect/atom-react'; + import React, { useState } from 'react'; + import { createRoot } from 'react-dom/client'; + import type { RouteManifest } from ${JSON.stringify(join(root, 'packages/agent-bundle/src/contracts/routes.ts'))}; + import { routeEditorKey } from ${JSON.stringify(join(root, 'packages/workbench/src/routes/route-editor-atoms.ts'))}; + import { routeCatalogFor } from ${JSON.stringify(join(root, 'packages/workbench/src/routes/routes-model.ts'))}; + import { RoutesPage } from ${JSON.stringify(join(root, 'packages/workbench/src/routes/routes-page.tsx'))}; + + const catalogFor = (digest: string) => routeCatalogFor({ + cli: { + commands: [{ + aliases: [], + exitCode: 'zero', + options: [ + { key: 'input', kind: 'string', option: 'input', positional: 0, repeated: false, required: true }, + ], + path: ['library', 'audit'], + routeId: 'cli:library/audit', + }], + mode: 'generated', + routes: [{ + config: [], + id: 'cli:library/audit', + inputSchema: { + additionalProperties: false, + properties: { input: { type: 'string' } }, + required: ['input'], + type: 'object', + }, + kind: 'cli', + provenance: { kind: 'conventional' }, + source: 'src/cli/library/audit.ts', + }], + }, + diagnostics: [], + digest, + events: [], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:library', + mode: 'generated', + name: 'library', + routes: [{ + config: [], + id: 'tool:library/search', + inputSchema: { + additionalProperties: false, + properties: { query: { type: 'string' } }, + required: ['query'], + type: 'object', + }, + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:library', + source: 'src/mcp/library/tools/search.ts', + }], + }], + sourceRevision: 'source', + } satisfies RouteManifest); + + const Fixture = () => { + const [state, setState] = useState({ digest: '', mounted: false }); + window.__routeEditorAtoms = { + mount: (digest: string) => { setState({ digest, mounted: true }); }, + unmount: () => { setState((current) => ({ ...current, mounted: false })); }, + }; + return state.mounted + ?
+ +
+ :

Routes unmounted

; + }; + + createRoot(document.getElementById('root')!).render( + , + ); +`; + +describe('Route editor atoms', () => { + it('releases editor state across repeated unmounts and digest switches', async () => { + const root = process.cwd(); + const temp = await mkdtemp(join(root, 'packages/workbench/.route-editor-atoms-')); + const entry = join(temp, 'route-editor-atoms-fixture.tsx'); + const output = join(temp, 'dist'); + await writeFile(entry, fixtureSource(root)); + const config: RsbuildConfig = createWorkbenchConfig(); + config.source = { + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + entry: { 'route-editor-atoms-fixture': entry }, + }; + config.output = { ...config.output, distPath: { root: output } }; + const rsbuild = await createRsbuild({ config }); + const buildResult = await rsbuild.build(); + await buildResult.close(); + const { server, url } = await startStaticServer(output); + const browser = await chromium.launch({ channel: 'chrome' }); + try { + const page = await browser.newPage(); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.stack ?? error.message)); + await page.goto(url, { timeout: 5_000, waitUntil: 'domcontentloaded' }); + await page.getByText('Routes unmounted', { exact: true }).waitFor({ timeout: 5_000 }); + + const cliEditor = page.getByRole('region', { name: 'Input for cli:library/audit' }); + const input = cliEditor.getByLabel('Input (required)'); + const invocation = cliEditor.getByLabel('Generated argv invocation'); + const digestA = 'a'.repeat(64); + const digestB = 'b'.repeat(64); + + for (let cycle = 1; cycle <= 5; cycle += 1) { + await page.evaluate(({ digest }) => window.__routeEditorAtoms.mount(digest), { digest: digestA }); + await input.fill(`cycle-${String(cycle)}`); + await cliEditor.getByRole('button', { name: 'Validate input' }).click(); + await expect.poll(() => invocation.inputValue()).toBe(`library audit cycle-${String(cycle)}`); + + await page.evaluate(() => window.__routeEditorAtoms.unmount()); + await page.getByText('Routes unmounted', { exact: true }).waitFor({ timeout: 5_000 }); + await page.evaluate(({ digest }) => window.__routeEditorAtoms.mount(digest), { digest: digestA }); + await expect.poll(() => input.inputValue()).toBe(''); + await expect.poll(() => invocation.count()).toBe(0); + await expect.poll(() => cliEditor.locator('.route-input-error').count()).toBe(0); + await page.evaluate(() => window.__routeEditorAtoms.unmount()); + } + + await page.evaluate(({ digest }) => window.__routeEditorAtoms.mount(digest), { digest: digestA }); + await input.fill('digest-a'); + await cliEditor.getByRole('button', { name: 'Validate input' }).click(); + await expect.poll(() => invocation.inputValue()).toBe('library audit digest-a'); + await page.evaluate(({ digest }) => window.__routeEditorAtoms.mount(digest), { digest: digestB }); + await expect.poll(() => input.inputValue()).toBe(''); + await expect.poll(() => invocation.count()).toBe(0); + await page.evaluate(({ digest }) => window.__routeEditorAtoms.mount(digest), { digest: digestA }); + await expect.poll(() => input.inputValue()).toBe(''); + await expect.poll(() => invocation.count()).toBe(0); + expect(errors).toEqual([]); + } finally { + await browser.close(); + await new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))); + await rm(temp, { force: true, recursive: true }); + } + }, 60_000); +}); diff --git a/packages/workbench/tests/routes-editor-state.test.ts b/packages/workbench/tests/routes-editor-state.test.ts new file mode 100644 index 000000000..7faea0afd --- /dev/null +++ b/packages/workbench/tests/routes-editor-state.test.ts @@ -0,0 +1,151 @@ +import { expect, it } from '@rstest/core'; + +import type { + RouteInputSchema, + RouteManifestCliCommand, +} from '../../agent-bundle/src/contracts/routes.ts'; +import { + createRouteInputDraft, + initialRouteEditorState, + setRouteEditorDraftValue, + setRouteEditorRaw, + validateRouteEditor, + type RouteEditorState, +} from '../src/routes/routes-model.ts'; + +const typedSchema: RouteInputSchema = { + additionalProperties: false, + properties: { + enabled: { type: 'boolean' }, + source: { items: { type: 'string' }, type: 'array' }, + }, + required: ['source'], + type: 'object', +}; + +const repeatedCommand: RouteManifestCliCommand = { + aliases: [], + exitCode: 'zero', + options: [ + { key: 'source', kind: 'string', option: 'source', repeated: true, required: true }, + { key: 'enabled', kind: 'boolean', option: 'enabled', repeated: false, required: false }, + ], + path: ['library', 'import'], + routeId: 'cli:library/import', +}; + +const rawCommand: RouteManifestCliCommand = { + aliases: [], + exitCode: 'zero', + options: [ + { key: 'input', kind: 'string', option: 'input', positional: 0, repeated: false, required: true }, + ], + path: ['library', 'audit'], + routeId: 'cli:library/audit', +}; + +it('creates frozen initial editor state with the existing draft defaults', () => { + const typed = initialRouteEditorState(typedSchema); + const raw = initialRouteEditorState(); + + expect(typed).toEqual({ + attempted: false, + draft: createRouteInputDraft(typedSchema), + errors: {}, + raw: '{}', + }); + expect(raw).toEqual({ + attempted: false, + draft: {}, + errors: {}, + raw: '{}', + }); + expect(Object.isFrozen(typed)).toBe(true); + expect(Object.isFrozen(typed.draft)).toBe(true); + expect(Object.isFrozen(typed.errors)).toBe(true); +}); + +it('changes the draft before validation without producing validation output', () => { + const initial = initialRouteEditorState(typedSchema); + const edited = setRouteEditorDraftValue(initial, typedSchema, repeatedCommand, 'source', ['audio']); + + expect(edited).toEqual({ + attempted: false, + draft: { source: ['audio'] }, + errors: {}, + raw: '{}', + }); +}); + +it('revalidates typed input after an attempt and projects repeated argv options', () => { + const attempted = validateRouteEditor(initialRouteEditorState(typedSchema), typedSchema, repeatedCommand); + expect(attempted.errors).toEqual({ source: 'Source is required.' }); + + const valid = setRouteEditorDraftValue(attempted, typedSchema, repeatedCommand, 'source', ['audio', 'books']); + + expect(valid.errors).toEqual({}); + expect(valid.arguments).toEqual({ source: ['audio', 'books'] }); + expect(valid.argv).toBe('library import --source audio --source books'); + expect(Object.isFrozen(valid)).toBe(true); + expect(Object.isFrozen(valid.errors)).toBe(true); +}); + +it('preserves optional boolean omission through true, false, and undefined', () => { + const attempted = validateRouteEditor(initialRouteEditorState(typedSchema), typedSchema, repeatedCommand); + const withSource = setRouteEditorDraftValue(attempted, typedSchema, repeatedCommand, 'source', ['audio']); + const enabled = setRouteEditorDraftValue(withSource, typedSchema, repeatedCommand, 'enabled', true); + const disabled = setRouteEditorDraftValue(enabled, typedSchema, repeatedCommand, 'enabled', false); + const omitted = setRouteEditorDraftValue(disabled, typedSchema, repeatedCommand, 'enabled', undefined); + + expect(enabled.arguments).toEqual({ enabled: true, source: ['audio'] }); + expect(disabled.arguments).toEqual({ enabled: false, source: ['audio'] }); + expect(omitted.arguments).toEqual({ source: ['audio'] }); + expect(Object.hasOwn(omitted.draft, 'enabled')).toBe(false); +}); + +it('deletes a draft key when the next value is undefined', () => { + const withValue = setRouteEditorDraftValue( + initialRouteEditorState(typedSchema), + typedSchema, + repeatedCommand, + 'source', + ['audio'], + ); + const deleted = setRouteEditorDraftValue(withValue, typedSchema, repeatedCommand, 'source', undefined); + + expect(Object.hasOwn(deleted.draft, 'source')).toBe(false); +}); + +it('revalidates raw text after an attempt without changing the prior argv', () => { + const withRaw = setRouteEditorRaw(initialRouteEditorState(), '{"input":"before"}'); + const attempted = validateRouteEditor(withRaw, undefined, rawCommand); + expect(attempted.argv).toBe('library audit before'); + + const changed = setRouteEditorRaw(attempted, '{"input":"after"}'); + expect(changed.arguments).toEqual({ input: 'after' }); + expect(changed.rawError).toBeUndefined(); + expect(changed.argv).toBe('library audit before'); + + const invalid = setRouteEditorRaw(changed, '{'); + expect(invalid.arguments).toBeUndefined(); + expect(invalid.rawError).toBe('Enter a valid JSON object.'); + expect(invalid.argv).toBe('library audit before'); +}); + +it('keeps typed and raw validation errors isolated to their respective paths', () => { + const rawSeed: RouteEditorState = Object.freeze({ + ...initialRouteEditorState(), + errors: Object.freeze({ retained: 'typed error' }), + }); + const rawValidated = validateRouteEditor(setRouteEditorRaw(rawSeed, '{'), undefined, rawCommand); + expect(rawValidated.errors).toEqual({ retained: 'typed error' }); + expect(rawValidated.rawError).toBe('Enter a valid JSON object.'); + + const typedSeed: RouteEditorState = Object.freeze({ + ...initialRouteEditorState(typedSchema), + rawError: 'retained raw error', + }); + const typedValidated = validateRouteEditor(typedSeed, typedSchema, repeatedCommand); + expect(typedValidated.errors).toEqual({ source: 'Source is required.' }); + expect(typedValidated.rawError).toBe('retained raw error'); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 563f6c86d..746c3eceb 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -65,6 +65,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/workbench/tests/playground-real.e2e.test.ts', 'packages/workbench/tests/rsbuild-closure.test.ts', 'packages/workbench/tests/rsbuild-workbench.test.ts', + 'packages/workbench/tests/route-editor-atoms-disposal.test.ts', 'packages/workbench/tests/runtime-document-atoms-disposal.test.ts', 'packages/workbench/tests/runtime-inspector.test.ts', 'packages/workbench/tests/runtime-consent-dialog.test.ts',