• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
|
||||
// model through inspect, mount, and unmount; replay pins the same shipped Web
|
||||
// composition, durable calls, generic rows, highlighted Plugin source, and
|
||||
// conversation accessibility tree.
|
||||
// model through inspect, define, run, and stop; replay pins the same shipped Web
|
||||
// composition, durable calls, generic rows, the define card's own source view,
|
||||
// and conversation accessibility tree.
|
||||
//
|
||||
// The approval is never in the fixture. The fixture pins what the MODEL said;
|
||||
// tools execute for real, so `cordis_run` genuinely blocks on a person and this
|
||||
// test is that person — which is what lets the run/approve boundary be asserted
|
||||
// instead of assumed. The package therefore carries a browser half whose only
|
||||
// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
|
||||
// and presence after it is the v3 user gate, proven rather than described.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
@@ -17,12 +24,21 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const
|
||||
const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
|
||||
const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". '
|
||||
+ `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. `
|
||||
+ 'Read its returned id and call cordis_unmount with that exact id. '
|
||||
+ 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.'
|
||||
const CORDIS_TOOLS = ['cordis_runtime_inspect', 'cordis_package_inspect', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
|
||||
const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
|
||||
// The browser half is the PROBE this scenario turns on: it renders a marker into
|
||||
// the frame-wide overlay, so "did the plugin actually run in this page" becomes a
|
||||
// DOM fact. A host-only package would sidestep the approval round trip entirely
|
||||
// (the host runs those immediately), which would drop the v3 user gate out of
|
||||
// coverage — the one thing this scenario exists to prove.
|
||||
const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
|
||||
+ '{ name: "shell.overlay", id: "snapshot-probe" }, '
|
||||
+ '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
|
||||
const PROMPT = 'Use only Cordis tools. First call cordis_runtime_inspect with what "temporary". '
|
||||
+ 'Then call cordis_define with name "snapshot noop", purpose "does nothing, for the snapshot", '
|
||||
+ `code exactly ${JSON.stringify(PACKAGE_CODE)} and client exactly ${JSON.stringify(CLIENT_CODE)}. `
|
||||
+ 'Read its returned id and call cordis_run with that exact id, then cordis_stop with the same id. '
|
||||
+ 'After all four calls succeed, reply exactly CORDIS_UI_DONE and stop.'
|
||||
|
||||
function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
|
||||
const turnEnd = events.findLast(
|
||||
@@ -82,6 +98,22 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
|
||||
// `cordis_run` blocks host-side on a person's answer — no timer, no default.
|
||||
// The approval is the TEST's action in every mode: the fixture pins what the
|
||||
// model said, and the gate is a real round trip through the real panel.
|
||||
const badge = page.locator('[data-cordis-badge]')
|
||||
await expect.poll(() => badge.getAttribute('data-cordis-awaiting'), { timeout: 90_000 }).toBe('true')
|
||||
await badge.click()
|
||||
const approve = page.locator('[data-cordis-approve]').first()
|
||||
await approve.waitFor({ timeout: 10_000 })
|
||||
// The one assertion this scenario cannot give up: the model asking to run is
|
||||
// NOT the plugin running. Until a person answers, the browser half has not
|
||||
// been fetched, evaluated, or mounted anywhere on this page.
|
||||
expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
|
||||
await approve.click()
|
||||
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
assertCompleteCordisLifecycle(sessionEvents)
|
||||
@@ -100,20 +132,30 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first()
|
||||
const inspectRow = page.locator('[data-tool="cordis_runtime_inspect"]').filter({ hasText: 'Inspect' }).first()
|
||||
await inspectRow.waitFor({ timeout: 10_000 })
|
||||
|
||||
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
|
||||
await mountRow.waitFor({ timeout: 10_000 })
|
||||
// cordis_define does NOT go through the generic row: ui-cordis registers a
|
||||
// keyed toolview for it, and a keyed hit replaces the generic card. So the
|
||||
// title here is the CARD's ("Cordis Plugin"), and the expanded body is the
|
||||
// card's own two code sections rather than a generic args dump.
|
||||
const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
|
||||
await defineRow.waitFor({ timeout: 10_000 })
|
||||
// The whole summary row is the expand toggle (unified tool-row interaction).
|
||||
await mountRow.locator('[aria-expanded]').first().click()
|
||||
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
|
||||
.toContain(MOUNT_CODE)
|
||||
await defineRow.locator('[aria-expanded]').first().click()
|
||||
await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain(PACKAGE_CODE)
|
||||
await expect.poll(() => defineRow.textContent()).toContain('data-snapshot-probe')
|
||||
|
||||
const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first()
|
||||
await unmountRow.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => unmountRow.textContent()).toContain('dyn-')
|
||||
await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok')
|
||||
const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run dynamic package' }).first()
|
||||
await runRow.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => runRow.textContent()).toContain('dyn-')
|
||||
|
||||
const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop dynamic package' }).first()
|
||||
await stopRow.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => stopRow.textContent()).toContain('dyn-')
|
||||
await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
|
||||
// Stopping withdraws the browser half from every page, probe included.
|
||||
await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
|
||||
|
||||
Reference in New Issue
Block a user