mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-01 03:20:37 +00:00
test(design): qualify the Design companion end to end and the panel sandbox on native hosts (#1208)
Slice 6 (qa-native) of the Design image companion (SCR v1.3.1; AC-16, AC-41, AC-52, AC-54). Test code only; no product source changes. - `scripts/test-design-companion-native.mjs`: isolated OpenCode daemon, scripted chat model, fake image model; AC-16 and native AC-52 (re-read 35 assistant messages later). Added to the PR build on Windows/Ubuntu/macOS. - `--ui <Design ZIP>` → `scripts/test-design-companion-ui.mjs` + `fixtures/design-companion.tsx`: real UI S8 path with the installed extension and real-transcript Markdown rendering (AC-52, AC-54). - `scripts/panel-extension-native-probe.mjs`, `test-panel-extension-native.mjs --api4` (also off Windows), new `test-panel-extension-electron.mjs`: sandbox invariants with negative controls (AC-41). - `fixture-exemptions.ts`: registers the Images conversation and API 4 browser tests required by `fixture-wiring`. Runs: Windows (OpenCode 2.2.1 native + UI path; WebView2 and Electron runners), Ubuntu VM (WebKitGTK and Electron runners). **Not run:** macOS WKWebView — the VM stops at its login screen.
This commit is contained in:
parent
4124b60b21
commit
f39642bc4e
14 changed files with 1156 additions and 111 deletions
10
.github/workflows/pr-build.yml
vendored
10
.github/workflows/pr-build.yml
vendored
|
|
@ -114,6 +114,15 @@ jobs:
|
|||
node --import tsx --test packages/server/src/opencode/pruning-installation.test.ts
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
node scripts/run-session-pruning-native.mjs $cli --ui
|
||||
- name: Design companion end to end with an isolated official runtime
|
||||
shell: pwsh
|
||||
run: |
|
||||
$platform = node -p "process.platform === 'win32' ? 'windows' : process.platform"
|
||||
$arch = node -p "process.arch"
|
||||
$extension = node -p "process.platform === 'win32' ? '.exe' : ''"
|
||||
$cli = Join-Path $env:PRUNING_NATIVE_INSTALL "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
|
||||
# Scripted chat and fake image models: series, proof, lineage, pixels and the late read-back.
|
||||
node scripts/test-design-companion-native.mjs $cli
|
||||
- name: Upload isolated native failure diagnostics
|
||||
if: ${{ failure() && env.PRUNING_NATIVE_FIXTURES != '' }}
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
|
|
@ -125,6 +134,7 @@ jobs:
|
|||
${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/discovery.json
|
||||
${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/ui-*.png
|
||||
${{ env.PRUNING_NATIVE_FIXTURES }}/codenomad-pruning-native-*/test-diagnostics.jsonl
|
||||
${{ env.PRUNING_NATIVE_FIXTURES }}/design-companion-*/daemon.log
|
||||
${{ env.PRUNING_NATIVE_FIXTURES }}/native-fixture-run-*/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
|
|
|||
|
|
@ -402,9 +402,11 @@ only future online installation; installed copies and local ZIP installation rem
|
|||
node --import tsx --test packages/server/src/panel-extensions/extension.test.ts
|
||||
node --import tsx --test packages/server/src/panel-extensions/catalog.test.ts
|
||||
node --import tsx --test packages/server/src/opencode/session-pruning/assets.test.ts packages/server/src/server/routes/panel-extension-assets.test.ts
|
||||
node scripts/test-panel-extension-native.mjs # Windows, isolated Tauri/WebView2
|
||||
node scripts/test-panel-extension-native.mjs # isolated Tauri: WebView2, WKWebView or WebKitGTK
|
||||
node scripts/test-panel-extension-native.mjs --api2
|
||||
node scripts/test-panel-extension-native.mjs --api3
|
||||
node scripts/test-panel-extension-native.mjs --api4
|
||||
node scripts/test-panel-extension-electron.mjs # real preload, createWindow webPreferences; Linux may add --no-sandbox
|
||||
node --import tsx --test packages/server/src/forge/forge.test.ts packages/server/src/server/routes/panel-extension-work-items.test.ts
|
||||
node --conditions=browser --import tsx --test packages/ui/src/components/panel-extensions/work-items-channel.test.ts
|
||||
node --import tsx --test packages/ui/src/components/panel-extensions/image-bounds.test.ts
|
||||
|
|
@ -422,6 +424,38 @@ concurrent changes and corrupt-state preservation. Catalogue checks cover SSRF/r
|
|||
verification, metadata-only discovery and withdrawn selections after warm reads.
|
||||
Rendered tests use the real right panel, installer, routes and event dispatcher
|
||||
for online browse/search/consent, offline fallback, incompatibility and lifecycle.
|
||||
The native runners share `scripts/panel-extension-native-probe.mjs`: the frame is
|
||||
built from `PANEL_EXTENSION_SANDBOX` and `panelExtensionDocument` (the real panel
|
||||
must use that constant as its only sandbox), and must report `self.origin === "null"`,
|
||||
an unreachable `parent.document`, no native line besides the host's positive
|
||||
control, no request to the loopback leak counter (fetch, XHR, beacons, sockets,
|
||||
event streams, workers, images, scripts, styles, fonts, media, frames, objects,
|
||||
forms, popups) and exactly one port answer per declared method. Each run also hosts
|
||||
the same frame with `allow-same-origin` added and with the CSP meta removed, and
|
||||
fails unless the probe detects both.
|
||||
|
||||
The macOS (WKWebView) and Linux (WebKitGTK) runs are manual, on the macOS and
|
||||
Ubuntu test VMs, after each change to the frame document, bridge, panel iframe or
|
||||
desktop hosts. Each host needs a checkout of the change with `npm ci` (Electron's
|
||||
binary included), Node 22, Rust's `cargo` on `PATH` and Tauri 2's platform
|
||||
prerequisites (Xcode Command Line Tools; WebKitGTK 4.1 and libsoup 3 development
|
||||
packages). Both webviews need the desktop session of a logged-in user:
|
||||
|
||||
- **Ubuntu (GNOME Wayland)**: with the user logged in at the console, a shell over
|
||||
SSH reaches that session by exporting
|
||||
`WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus XDG_SESSION_TYPE=wayland`,
|
||||
then runs `node scripts/test-panel-extension-native.mjs` with no option and with
|
||||
`--api2`, `--api3` and `--api4`, and `node scripts/test-panel-extension-electron.mjs`.
|
||||
Electron's OS sandbox needs `node_modules/electron/dist/chrome-sandbox` owned by
|
||||
root with mode 4755; otherwise Electron refuses to start, and
|
||||
`--no-sandbox` runs the probe without that OS layer.
|
||||
- **macOS**: log in to the desktop at the VM console (after resuming, correct the
|
||||
clock with `sudo sntp -sS time.apple.com`), open Terminal in that session, and run
|
||||
the same five commands. Over SSH without a desktop login the WKWebView host
|
||||
never loads its page and the runner fails on its 60-second limit.
|
||||
|
||||
A run counts only when every command prints its summary and exits 0; record the
|
||||
host, OS version and each summary's `real` line in the review.
|
||||
|
||||
## Agent conventions
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,13 @@ restart) and gallery membership on that stored tool file. Unit tests cover rollb
|
|||
and client projection/read races; the gallery tests replay their proof mutations on
|
||||
copies of two OpenCode 2.2.1 Images transcripts, with and without media blobs
|
||||
(`assets-gallery-fixture.json`).
|
||||
`scripts/test-design-companion-native.mjs` drives an Images conversation end to end with a
|
||||
scripted chat model and a fake image model on the shipped bundle: two series of read images
|
||||
under `.codenomad/design/`, proven with prompt, lineage and pixels, then a completed
|
||||
generation without read-back whose reference is sent and read more than 32 assistant
|
||||
messages later (pixels, unverifiable origin). With `--ui <Design ZIP>` the same request is
|
||||
prefilled from the installed Design extension through the real right panel, API 4 routes
|
||||
and composer, sent, and its relative Markdown image is checked in the real transcript.
|
||||
The mock's usage numbers are synthetic; never cite them as provider token savings.
|
||||
|
||||
Unvalidated: native WSL runs, interactive TUI, two desktop windows/scroll,
|
||||
|
|
@ -225,6 +232,8 @@ node --import tsx --test packages/server/src/server/routes/images-conversation.t
|
|||
node --import tsx --test tests/browser/images-conversation.test.ts # from packages/ui
|
||||
node --import tsx --conditions=browser --test --test-force-exit packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-pruning-events.test.ts packages/ui/src/stores/session-pruning-projection.test.ts packages/ui/src/stores/opencode-data.test.ts
|
||||
node scripts/test-session-pruning-native.mjs C:/isolated-install/opencode2.exe
|
||||
node scripts/test-design-companion-native.mjs C:/isolated-install/opencode2.exe # gallery end to end, scripted chat and image models
|
||||
node scripts/test-design-companion-native.mjs C:/isolated-install/opencode2.exe --ui C:/abs/neuralnomads.design-1.0.0.zip # plus the Design panel, composer and transcript
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
npm run typecheck --workspace @codenomad/ui
|
||||
npm run build --workspace @codenomad/ui
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ fn probe(role: String) { println!("NATIVE:{role}"); }
|
|||
fn main() {
|
||||
let url = std::env::args().nth(1).expect("isolated fixture URL");
|
||||
let profile = std::env::args().nth(2).expect("isolated browser profile");
|
||||
let seconds = std::env::args().nth(3).map_or(6, |value| value.parse().expect("lifetime in seconds"));
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![probe])
|
||||
.setup(move |app| {
|
||||
|
|
@ -13,7 +14,7 @@ fn main() {
|
|||
.build()?;
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(6));
|
||||
std::thread::sleep(std::time::Duration::from_secs(seconds));
|
||||
handle.exit(0);
|
||||
});
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@
|
|||
"permissions": ["allow-probe"]
|
||||
}] }
|
||||
},
|
||||
"bundle": { "active": false, "icon": ["../../src-tauri/icon.ico"] }
|
||||
"bundle": { "active": false, "icon": ["../../src-tauri/icon.ico", "../../src-tauri/icon.png"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export const exemptions: Record<string, string> = {
|
|||
"hotkeys-settings.test.ts": "Vite config diverges from the canonical daemon config",
|
||||
"http-admission-responsiveness.test.ts": "Vite config diverges from the canonical daemon config",
|
||||
"icon-toggle-active.test.ts": "served by a self-hosting harness or inline server that owns its Vite server",
|
||||
"images-conversation.test.ts": "served by a harness that borrows from the daemon",
|
||||
"interruption-dock.test.ts": "served by a self-hosting harness or inline server that owns its Vite server",
|
||||
"markdown-latest-request.test.ts": "custom Vite transform/load hook",
|
||||
"message-scroll.test.ts": "stateful SSE middleware driven by the test",
|
||||
|
|
@ -47,6 +48,7 @@ export const exemptions: Record<string, string> = {
|
|||
"mission-recurrence-predispatch.test.ts": "no fixture page of its own",
|
||||
"native-mission-integration.qualification.ts": "qualification harness with capture middleware on its own server",
|
||||
"notification-icon-badge.test.ts": "Vite config diverges from the canonical daemon config",
|
||||
"panel-extension-api4.test.ts": "served by a harness that borrows from the daemon",
|
||||
"panel-extensions.test.ts": "Vite config diverges: stateful /api proxy to an app the test drives",
|
||||
"plugin-packages.test.ts": "served by a harness that borrows from the daemon",
|
||||
"preferences-startup-state.test.ts": "Vite config diverges from the canonical daemon config",
|
||||
|
|
|
|||
83
packages/ui/tests/browser/fixtures/design-companion.tsx
Normal file
83
packages/ui/tests/browser/fixtures/design-companion.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Show, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import SessionView from "../../../src/components/session/session-view"
|
||||
import RightPanel from "../../../src/components/instance/shell/right-panel/RightPanel"
|
||||
import type { PromptInputApi } from "../../../src/components/prompt-input/types"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider, useI18n } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
import { addInstance, setActiveInstanceId } from "../../../src/stores/instances"
|
||||
import { activeSessionId, sessions, setActiveParentSession, setProviders } from "../../../src/stores/session-state"
|
||||
import { fetchAgents, hydrateRestoredSessionChain } from "../../../src/stores/session-api"
|
||||
import { updateSessionAgent } from "../../../src/stores/session-actions"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import "../../../src/index.css"
|
||||
|
||||
// The Design companion against an isolated OpenCode runtime (scripts/test-design-companion-ui.mjs):
|
||||
// the native client, transcript, composer, right panel, extension bridge and API 4 channel are
|
||||
// real; only workspace chrome is seeded and native events arrive through the fixture stream.
|
||||
await applyUiSettings({ locale: "en" })
|
||||
const params = new URLSearchParams(location.search)
|
||||
const instanceId = "design-ui"
|
||||
const sessionId = params.get("session")!
|
||||
const directory = params.get("directory")!
|
||||
const proxyPath = `/workspaces/${instanceId}/instance`
|
||||
const client = sdkManager.createClient(instanceId, proxyPath)
|
||||
addInstance({ id: instanceId, folder: directory, port: 0, pid: 0, proxyPath, status: "ready", client })
|
||||
setActiveInstanceId(instanceId)
|
||||
setProviders(prev => new Map(prev).set(instanceId, [{ id: "fixture", name: "Fixture", defaultModelId: "vision", models: [
|
||||
{ id: "vision", name: "Fixture vision", providerId: "fixture", limit: { context: 100000, output: 1000 }, cost: { input: 0, output: 0 } },
|
||||
] }]))
|
||||
// Like the app, history loads once the native event stream is connected; events wait for the
|
||||
// session's own read, as the app's session list precedes them.
|
||||
let eventsReady = false, buffered: unknown[] | undefined = []
|
||||
const dispatch = (event: unknown) => (serverEvents as any).dispatchBatch([{ type: "instance.event", instanceId, event }])
|
||||
const connected = new Promise<void>(resolve => {
|
||||
const events = new EventSource("/fixture-events")
|
||||
events.onmessage = message => {
|
||||
eventsReady = true
|
||||
const event = JSON.parse(message.data)
|
||||
if (buffered) buffered.push(event)
|
||||
else dispatch(event)
|
||||
resolve()
|
||||
}
|
||||
window.addEventListener("pagehide", () => events.close(), { once: true })
|
||||
})
|
||||
await connected
|
||||
await fetchAgents(instanceId, { directory }, true)
|
||||
await hydrateRestoredSessionChain(instanceId, [sessionId])
|
||||
setActiveParentSession(instanceId, sessionId)
|
||||
for (const event of buffered.splice(0)) dispatch(event)
|
||||
buffered = undefined
|
||||
|
||||
const [apis, setApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||
const active = () => activeSessionId().get(instanceId) ?? null
|
||||
const promptApi = () => { const id = active(); return id ? apis()[id] ?? null : null }
|
||||
|
||||
function Fixture() {
|
||||
const { t } = useI18n()
|
||||
return <div style={{ display: "flex", height: "100dvh", width: "100vw" }}>
|
||||
<div style={{ flex: "1", "min-width": "0", display: "flex" }}>
|
||||
<Show when={active()} keyed>{id =>
|
||||
<SessionView sessionId={id} activeSessions={sessions().get(instanceId)!} instanceId={instanceId} instanceFolder={directory}
|
||||
escapeInDebounce={false} isActive={true} onAgentChange={agentId => updateSessionAgent(instanceId, id, agentId)} onModelChange={async () => {}}
|
||||
registerSessionPromptApi={(id, api) => setApis(current => ({ ...current, [id]: api }))} />
|
||||
}</Show>
|
||||
</div>
|
||||
<div class="session-shell-panels" style={{ width: "460px", position: "relative" }}>
|
||||
<RightPanel instanceId={instanceId} instance={{ id: instanceId, folder: directory, status: "ready", client: null, port: 0, pid: 0, proxyPath } as any}
|
||||
isActive={() => true} t={t} activeSessionId={active} activeSession={() => null} isPhoneLayout={() => false}
|
||||
rightDrawerWidth={() => 460} rightDrawerWidthInitialized={() => true} onCloseRightDrawer={() => {}} promptInputApi={promptApi} setContentEl={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
;(window as any).fixture = {
|
||||
eventsReady: () => eventsReady,
|
||||
sessions: () => JSON.parse(JSON.stringify([...(sessions().get(instanceId)?.values() ?? [])])),
|
||||
snapshot: () => ({ active: active(),
|
||||
draft: (document.querySelector("textarea.prompt-input") as HTMLTextAreaElement | null)?.value ?? null,
|
||||
focused: document.activeElement?.classList.contains("prompt-input") === true }),
|
||||
}
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
|
|
@ -9,13 +9,9 @@ import { setTimeout as delay } from "node:timers/promises"
|
|||
import { OpenCode } from "@opencode/client"
|
||||
import { stopFixtureChild } from "./native-fixture-guards.mjs"
|
||||
|
||||
// Shared by product-contract fixtures. Explicit owned process, random password,
|
||||
// private discovery/database/home, and a loopback synthetic provider only.
|
||||
// `projectConfig` lets the daemon read a fixture project's own `.opencode/` folder. Project discovery
|
||||
// walks every ancestor directory, so outside CI the fixture must live outside the user's home
|
||||
// (`CODENOMAD_NATIVE_FIXTURE_PARENT`), or it would read the user's own agents and skills.
|
||||
export async function withProductRuntime(cli, prepare, run, { projectConfig = false } = {}) {
|
||||
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
||||
/** The resolved `opencode` folder fixtures are created in. With `projectConfig` the daemon reads
|
||||
* every ancestor's `.opencode/`, so outside CI it must lie outside the user's home. */
|
||||
export async function fixtureParent({ projectConfig = false } = {}) {
|
||||
const requested = path.join(projectConfig && process.env.CODENOMAD_NATIVE_FIXTURE_PARENT || os.tmpdir(), "opencode")
|
||||
await mkdir(requested, { recursive: true })
|
||||
// The resolved path is both checked and used, so a link out of the home cannot reintroduce its ancestors.
|
||||
|
|
@ -25,20 +21,53 @@ export async function withProductRuntime(cli, prepare, run, { projectConfig = fa
|
|||
assert(path.isAbsolute(relative) || relative.split(path.sep)[0] === "..",
|
||||
"Project-config fixtures read every ancestor directory: set CODENOMAD_NATIVE_FIXTURE_PARENT outside your home")
|
||||
}
|
||||
const root = await mkdtemp(path.join(parent, "product-native-"))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|npm_config_)/i.test(key)))
|
||||
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
// 2.0.29+ reads system-managed settings from %ProgramData%\opencode on Windows.
|
||||
if (process.platform === "win32") {
|
||||
for (const key of Object.keys(env)) if (key.toLowerCase() === "programdata") delete env[key]
|
||||
env.ProgramData = path.join(root, "ProgramData")
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
// Inherited only to run binaries: never provider credentials, cloud SDK settings, Git
|
||||
// variables or user profile paths (a daemon reads all of them for provider discovery).
|
||||
const INHERITED = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|SYSTEMDRIVE|OS|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|COMMONPROGRAMFILES|COMMONPROGRAMFILES\(X86\)|COMMONPROGRAMW6432|LANG|LANGUAGE|LC_[A-Z_]+|TZ|CI|SHELL|USER|LOGNAME|TERM)$/i
|
||||
|
||||
/** The environment of a fixture-owned child: allowlisted process variables, then a private
|
||||
* home, profile, temp, XDG and npm tree under `root`, then `extra`. */
|
||||
export async function fixtureEnvironment(root, extra = {}) {
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => INHERITED.test(key)))
|
||||
const temp = path.join(root, "tmp")
|
||||
Object.assign(env, {
|
||||
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
APPDATA: path.join(root, "AppData", "Roaming"), LOCALAPPDATA: path.join(root, "AppData", "Local"),
|
||||
// 2.0.29+ reads system-managed settings from %ProgramData%\opencode on Windows.
|
||||
ProgramData: path.join(root, "ProgramData"), TEMP: temp, TMP: temp, TMPDIR: temp,
|
||||
XDG_CONFIG_HOME: path.join(root, "XDG_CONFIG_HOME"), XDG_DATA_HOME: path.join(root, "XDG_DATA_HOME"),
|
||||
XDG_STATE_HOME: path.join(root, "XDG_STATE_HOME"), XDG_CACHE_HOME: path.join(root, "XDG_CACHE_HOME"),
|
||||
npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache"),
|
||||
...extra,
|
||||
})
|
||||
for (const directory of [env.APPDATA, env.LOCALAPPDATA, env.ProgramData, temp]) await mkdir(directory, { recursive: true })
|
||||
assertFixtureEnvironment(env)
|
||||
return env
|
||||
}
|
||||
|
||||
/** Every key a fixture child receives from this process is allowlisted; the rest is the fixture's own. */
|
||||
export function assertFixtureEnvironment(env) {
|
||||
const leaked = Object.keys(env).filter(key => process.env[key] !== undefined && process.env[key] === env[key] && !INHERITED.test(key))
|
||||
assert.deepEqual(leaked, [], "A fixture child inherits only allowlisted variables")
|
||||
assert(Object.keys(env).every(key => !/^(GIT_|CLOUDSDK_|GOOGLE_|AWS_|AZURE_)|_API_KEY$/i.test(key)), "No Git, cloud or provider credential variable reaches a fixture child")
|
||||
}
|
||||
|
||||
// Shared by product-contract fixtures. Explicit owned process, random password,
|
||||
// private discovery/database/home, and a loopback synthetic provider only.
|
||||
// `projectConfig` lets the daemon read a fixture project's own `.opencode/` folder. Project discovery
|
||||
// walks every ancestor directory, so outside CI the fixture must live outside the user's home
|
||||
// (`CODENOMAD_NATIVE_FIXTURE_PARENT`), or it would read the user's own agents and skills.
|
||||
export async function withProductRuntime(cli, prepare, run, { projectConfig = false } = {}) {
|
||||
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
||||
const root = await mkdtemp(path.join(await fixtureParent({ projectConfig }), "product-native-"))
|
||||
const password = randomUUID()
|
||||
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
const env = await fixtureEnvironment(root, {
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
||||
...(projectConfig ? {} : { OPENCODE_CONFIG_PROJECT_DISABLE: "1" }), OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
OPENCODE_SERVER_PASSWORD: password })
|
||||
Object.assign(env, { npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache") })
|
||||
await mkdir(env.OPENCODE_CONFIG_DIR)
|
||||
const configuration = await prepare({ root, configDirectory: env.OPENCODE_CONFIG_DIR })
|
||||
const requests = []
|
||||
|
|
|
|||
281
scripts/panel-extension-native-probe.mjs
Normal file
281
scripts/panel-extension-native-probe.mjs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// Shared native isolation probe for panel extension frames (Tauri and Electron hosts).
|
||||
// A loopback server serves one host page per mode, answers the frame's port requests,
|
||||
// collects the frame's report and counts every other request as a leak.
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
|
||||
const product = new URL("../packages/", import.meta.url)
|
||||
const HANDSHAKE = "fixture-handshake"
|
||||
const GRACE_MS = 1500
|
||||
|
||||
// Frame methods per API 4 permission (dev-docs/PANEL_EXTENSIONS.md#api-4); permissions
|
||||
// without frame methods map to none. A new permission must be added here.
|
||||
const API4_FRAME_METHODS = {
|
||||
"session.images.read": { images: ["status", "list", "read"] },
|
||||
"session.images.prompts": {},
|
||||
"composer.prefill": { composer: ["prefill"] },
|
||||
"images.conversation": { conversation: ["status", "open", "startNew"] },
|
||||
"opencode.designAgent": {},
|
||||
"panel.preferences": { preferences: ["get", "set"] },
|
||||
}
|
||||
const LEGACY_FRAME_METHODS = { 1: {}, 2: { assets: ["list", "read"] }, 3: { workItems: ["list", "open"] } }
|
||||
const LISTENERS = { 2: { assets: ["onChanged"] }, 4: { images: ["onChanged"] } }
|
||||
const ARGS = {
|
||||
"assets.read": ["probe-target", { thumbnail: true }], "workItems.list": [{ kind: "issue" }], "workItems.open": ["issue", 1],
|
||||
"images.read": ["probe-target", { thumbnail: true }], "composer.prefill": [{ text: "probe" }], "preferences.set": [{ probe: true }],
|
||||
}
|
||||
|
||||
export const MODES = ["real", "same-origin", "no-csp"]
|
||||
|
||||
/** Loads the product's frame policy and binds the probe to the real panel's iframe attributes. */
|
||||
export async function loadFramePolicy() {
|
||||
const frame = await tsImport(new URL("ui/src/components/panel-extensions/frame-document.ts", product).href, import.meta.url)
|
||||
const contract = await tsImport(new URL("server/src/panel-extensions/contract.ts", product).href, import.meta.url)
|
||||
const panel = await readFile(new URL("ui/src/components/panel-extensions/extension-panel.tsx", product), "utf8")
|
||||
assert.deepEqual(panel.match(/\bsandbox=\S+/g), ["sandbox={PANEL_EXTENSION_SANDBOX}"], "The real panel must use PANEL_EXTENSION_SANDBOX as its only sandbox")
|
||||
assert.match(panel, /panelExtensionDocument\(/, "The real panel must build its srcdoc with panelExtensionDocument")
|
||||
for (const permission of contract.PANEL_EXTENSION_API4_PERMISSIONS) {
|
||||
assert.ok(Object.hasOwn(API4_FRAME_METHODS, permission), `Probe has no frame method list for API 4 permission ${permission}`)
|
||||
}
|
||||
return { ...frame, api4Permissions: ["session.context", ...contract.PANEL_EXTENSION_API4_PERMISSIONS] }
|
||||
}
|
||||
|
||||
function frameMethods(apiVersion, permissions) {
|
||||
if (apiVersion !== 4) return LEGACY_FRAME_METHODS[apiVersion]
|
||||
const methods = {}
|
||||
for (const permission of permissions) for (const [object, names] of Object.entries(API4_FRAME_METHODS[permission] ?? {})) {
|
||||
methods[object] = [...(methods[object] ?? []), ...names]
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// Author code: identity, reachability, native and network attempts, then each declared method once.
|
||||
function authorHtml({ leakBase, methods, apiVersion }) {
|
||||
return `<h1>Native probe</h1><script>
|
||||
const results = { origin: self.origin, parentDocument: 'unknown', native: {}, globals: {}, leaks: {}, api: {}, surface: null, context: null, changed: null, errors: [] };
|
||||
try { void parent.document.title; results.parentDocument = 'reachable'; } catch { results.parentDocument = 'blocked'; }
|
||||
for (const [name, read] of Object.entries({
|
||||
__TAURI_INTERNALS__: () => window.__TAURI_INTERNALS__, __TAURI__: () => window.__TAURI__, electronAPI: () => window.electronAPI,
|
||||
require: () => window.require, process: () => window.process, ipc: () => window.ipc, ipcRenderer: () => window.ipcRenderer,
|
||||
chromeWebview: () => window.chrome && window.chrome.webview, webkitHandlers: () => window.webkit && window.webkit.messageHandlers,
|
||||
})) { try { results.globals[name] = typeof read(); } catch { results.globals[name] = 'threw'; } }
|
||||
const attempt = (name, run) => {
|
||||
try { const value = run(); results.native[name] = 'called';
|
||||
if (value && typeof value.then === 'function') value.then(() => { results.native[name] = 'resolved'; }, () => { results.native[name] = 'rejected'; });
|
||||
} catch { results.native[name] = 'threw'; }
|
||||
};
|
||||
const ipcMessage = role => JSON.stringify({ cmd: 'probe', callback: 1, error: 2, payload: { role } });
|
||||
attempt('tauri.invoke', () => __TAURI_INTERNALS__.invoke('probe', { role: 'child' }));
|
||||
attempt('tauri.ipc', () => __TAURI_INTERNALS__.ipc({ cmd: 'probe', callback: 1, error: 2, payload: { role: 'raw-child' } }));
|
||||
attempt('tauri.global', () => __TAURI__.core.invoke('probe', { role: 'global-child' }));
|
||||
attempt('tauri.parent', () => parent.__TAURI_INTERNALS__.invoke('probe', { role: 'parent-reach-child' }));
|
||||
attempt('tauri.top', () => top.__TAURI_INTERNALS__.invoke('probe', { role: 'top-reach-child' }));
|
||||
attempt('tauri.protocol', () => fetch('http://ipc.localhost/probe', { method: 'POST', body: '{"role":"protocol-child"}' }));
|
||||
attempt('tauri.ipcScheme', () => fetch('ipc://localhost/probe', { method: 'POST', body: '{"role":"scheme-child"}' }));
|
||||
attempt('webview2.postMessage', () => chrome.webview.postMessage(ipcMessage('webview-child')));
|
||||
attempt('webkit.postMessage', () => webkit.messageHandlers.ipc.postMessage(ipcMessage('webkit-child')));
|
||||
attempt('wry.ipc', () => window.ipc.postMessage(ipcMessage('wry-child')));
|
||||
attempt('electron.self', () => electronAPI.restartCli());
|
||||
attempt('electron.parent', () => parent.electronAPI.restartCli());
|
||||
attempt('electron.top', () => top.electronAPI.restartCli());
|
||||
attempt('electron.require', () => require('electron').ipcRenderer.invoke('cli:restart'));
|
||||
attempt('electron.process', () => process.electronBinding('ipc'));
|
||||
attempt('message', () => parent.postMessage({ cmd: 'probe', role: 'message-child' }, '*'));
|
||||
const leakBase = ${JSON.stringify(leakBase)};
|
||||
const add = (tag, attributes, where = document.body) => { const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value); where.append(node); return node; };
|
||||
const style = css => { const node = document.createElement('style'); node.textContent = css; document.head.append(node); };
|
||||
const leak = (kind, run) => { try { run(leakBase + kind); results.leaks[kind] = 'attempted'; } catch { results.leaks[kind] = 'threw'; } };
|
||||
leak('fetch', url => fetch(url).catch(() => {}));
|
||||
leak('fetch-no-cors', url => fetch(url, { mode: 'no-cors' }).catch(() => {}));
|
||||
leak('fetch-relative', () => fetch('/api/forbidden').catch(() => {}));
|
||||
leak('xhr', url => { const request = new XMLHttpRequest(); request.open('GET', url); request.send(); });
|
||||
leak('img', url => add('img', { src: url }));
|
||||
leak('img-srcset', url => add('img', { srcset: url + ' 1x' }));
|
||||
leak('script', url => add('script', { src: url }));
|
||||
leak('stylesheet', url => add('link', { rel: 'stylesheet', href: url }, document.head));
|
||||
leak('prefetch', url => add('link', { rel: 'prefetch', href: url }, document.head));
|
||||
leak('preload', url => add('link', { rel: 'preload', as: 'image', href: url }, document.head));
|
||||
leak('css-background', url => add('div', { style: 'width:8px;height:8px;background:url("' + url + '")' }));
|
||||
leak('css-import', url => style('@import url("' + url + '");'));
|
||||
leak('css-font', url => { style('@font-face{font-family:probe;src:url("' + url + '")}'); add('span', { style: 'font-family:probe' }).textContent = 'x'; });
|
||||
leak('iframe', url => add('iframe', { src: url }));
|
||||
leak('object', url => add('object', { data: url }));
|
||||
leak('embed', url => add('embed', { src: url }));
|
||||
leak('form', url => { add('iframe', { name: 'leak-target' }); add('form', { action: url, method: 'POST', target: 'leak-target' }).submit(); });
|
||||
leak('beacon', url => navigator.sendBeacon(url, 'probe'));
|
||||
leak('websocket', url => new WebSocket(url.replace(/^http:/, 'ws:')));
|
||||
leak('eventsource', url => new EventSource(url));
|
||||
leak('audio', url => add('audio', { src: url, preload: 'auto' }));
|
||||
leak('video', url => add('video', { src: url, preload: 'auto' }));
|
||||
leak('video-poster', url => add('video', { poster: url }));
|
||||
leak('svg-image', url => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); image.setAttribute('href', url); svg.append(image); document.body.append(svg); });
|
||||
leak('worker-blob', url => new Worker(URL.createObjectURL(new Blob(['fetch(' + JSON.stringify(url) + ').catch(() => {})'], { type: 'text/javascript' }))));
|
||||
leak('worker-data', url => new Worker('data:text/javascript,' + encodeURIComponent('fetch(' + JSON.stringify(url) + ').catch(() => {})')));
|
||||
leak('popup', url => window.open(url));
|
||||
const fired = Date.now();
|
||||
if (${apiVersion} === 2) codenomad.assets.onChanged(() => { results.changed = 'called'; });
|
||||
if (${apiVersion} === 4 && codenomad.images) codenomad.images.onChanged(detail => { results.changed = detail; });
|
||||
const methods = ${JSON.stringify(methods)}, args = ${JSON.stringify(ARGS)};
|
||||
let started = false;
|
||||
codenomad.onContext(async context => {
|
||||
if (started) return; started = true;
|
||||
results.context = context.sessionId;
|
||||
results.surface = Object.fromEntries(Object.keys(codenomad).map(key => [key, codenomad[key] && typeof codenomad[key] === 'object' ? Object.keys(codenomad[key]) : typeof codenomad[key]]));
|
||||
for (const [object, names] of Object.entries(methods)) for (const method of names) {
|
||||
const key = object + '.' + method;
|
||||
try { results.api[key] = await codenomad[object][method](...(args[key] || [])); } catch (error) { results.errors.push(key + ': ' + error.message); }
|
||||
}
|
||||
setTimeout(() => parent.postMessage({ type: 'results', results }, '*'), Math.max(0, ${GRACE_MS} - (Date.now() - fired)));
|
||||
});
|
||||
</script>`
|
||||
}
|
||||
|
||||
// Host page: positive native control, one-use port handshake, one answer per request.
|
||||
function hostPage({ mode, srcdoc, sandbox, apiVersion }) {
|
||||
const attribute = value => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<")
|
||||
return `<!doctype html><meta charset="utf-8"><title>Native probe host</title><script>
|
||||
const answers = [], unexpected = [];
|
||||
let positive = 'none';
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.__TAURI_INTERNALS__) { positive = 'tauri'; __TAURI_INTERNALS__.invoke('probe', { role: 'parent' }); }
|
||||
else if (window.electronAPI) { positive = 'electron'; window.electronAPI.getCliStatus(); }
|
||||
});
|
||||
window.addEventListener('message', event => {
|
||||
const frame = document.querySelector('iframe');
|
||||
if (event.source !== frame.contentWindow || event.data?.type !== 'results') return;
|
||||
fetch('/report/${mode}', { method: 'POST', body: JSON.stringify({ child: event.data.results, answers, unexpected, positive, hostOrigin: location.origin }) });
|
||||
});
|
||||
function initialize(frame) {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = event => {
|
||||
const data = event.data, request = /^(assets|workItems|api4):request$/.exec(data?.type);
|
||||
if (request) {
|
||||
const capability = request[1] === 'api4' ? data.capability : request[1];
|
||||
answers.push({ type: request[1], capability, method: data.method, input: data.input });
|
||||
channel.port1.postMessage({ type: request[1] + ':result', id: data.id, result: { probe: capability + '.' + data.method } });
|
||||
} else if (data?.type === 'ready' && data.handshake === ${JSON.stringify(HANDSHAKE)}) {
|
||||
channel.port1.postMessage({ type: 'context', context: { apiVersion: ${apiVersion}, sessionId: 'native-session', locale: 'en', appearance: 'light' } });
|
||||
if (${apiVersion} === 2) channel.port1.postMessage({ type: 'assets:changed' });
|
||||
if (${apiVersion} === 4) channel.port1.postMessage({ type: 'images:changed', gallery: true, status: false });
|
||||
} else unexpected.push(data?.type ?? typeof data);
|
||||
};
|
||||
frame.contentWindow.postMessage({ type: 'codenomad:init' }, '*', [channel.port2]);
|
||||
}
|
||||
</script><iframe sandbox="${attribute(sandbox)}" onload="initialize(this)" srcdoc="${attribute(srcdoc)}"></iframe>`
|
||||
}
|
||||
|
||||
/** Starts the loopback server serving `/host/<mode>`; everything outside host, report and favicon counts as a leak. */
|
||||
export async function startProbeServer({ policy, apiVersion }) {
|
||||
const permissions = apiVersion === 4 ? policy.api4Permissions : []
|
||||
const methods = frameMethods(apiVersion, permissions)
|
||||
const state = Object.fromEntries(MODES.map(mode => [mode, { reports: [], reportedAt: null, leaks: [] }]))
|
||||
let current = MODES[0]
|
||||
const record = (request, kind) => state[current].leaks.push(`${kind} ${request.method} ${request.url}`)
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1")
|
||||
const [, area, mode] = url.pathname.split("/")
|
||||
if (request.method === "GET" && area === "host" && MODES.includes(mode)) {
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8")
|
||||
response.end(hostPage({ mode, apiVersion, ...frameFor(mode) }))
|
||||
return
|
||||
}
|
||||
if (request.method === "POST" && area === "report" && MODES.includes(mode)) {
|
||||
let body = ""
|
||||
request.on("data", chunk => { body += chunk })
|
||||
request.on("end", () => { state[mode].reports.push(JSON.parse(body)); state[mode].reportedAt = Date.now(); response.end("ok") })
|
||||
return
|
||||
}
|
||||
if (url.pathname !== "/favicon.ico") record(request, "http")
|
||||
response.statusCode = url.pathname === "/favicon.ico" ? 404 : 204
|
||||
response.end()
|
||||
})
|
||||
server.on("upgrade", (request, socket) => { record(request, "upgrade"); socket.destroy() })
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const port = server.address().port
|
||||
const leakBase = `http://127.0.0.1:${port}/leak/`
|
||||
const document = policy.panelExtensionDocument(authorHtml({ leakBase, methods, apiVersion }), HANDSHAKE, apiVersion, permissions)
|
||||
const cspMeta = `<meta http-equiv="Content-Security-Policy" content="${policy.PANEL_EXTENSION_CSP}">`
|
||||
assert.ok(document.includes(cspMeta), "Cannot build the CSP-removed negative control: the CSP meta changed shape")
|
||||
function frameFor(mode) {
|
||||
if (mode === "same-origin") return { sandbox: `${policy.PANEL_EXTENSION_SANDBOX} allow-same-origin`, srcdoc: document }
|
||||
if (mode === "no-csp") return { sandbox: policy.PANEL_EXTENSION_SANDBOX, srcdoc: document.replace(cspMeta, "") }
|
||||
return { sandbox: policy.PANEL_EXTENSION_SANDBOX, srcdoc: document }
|
||||
}
|
||||
return {
|
||||
port, methods,
|
||||
url: mode => { current = mode; return `http://127.0.0.1:${port}/host/${mode}` },
|
||||
outcome: (mode, { output, exitedAt }) => ({ mode, apiVersion, methods, output, exitedAt, ...state[mode] }),
|
||||
close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Every isolation violation of one mode's outcome; the real mode must have none. */
|
||||
export function probeViolations(outcome) {
|
||||
const violations = []
|
||||
const fail = (category, message) => violations.push({ category, message })
|
||||
const nativeLines = outcome.output.split(/\r?\n/).map(line => line.trim()).filter(line => line.startsWith("NATIVE:"))
|
||||
const children = nativeLines.filter(line => line !== "NATIVE:parent")
|
||||
if (nativeLines.length - children.length !== 1) fail("positive-control", `expected one NATIVE:parent line, got ${JSON.stringify(nativeLines)}`)
|
||||
if (children.length) fail("native", `frame reached native commands: ${JSON.stringify(children)}`)
|
||||
if (outcome.leaks.length) fail("leak", `${outcome.leaks.length} leaked request(s): ${outcome.leaks.slice(0, 8).join(", ")}`)
|
||||
if (outcome.reports.length !== 1) { fail("report", `expected one frame report, got ${outcome.reports.length}`); return violations }
|
||||
if (outcome.exitedAt - outcome.reportedAt < GRACE_MS) fail("grace", `host exited ${outcome.exitedAt - outcome.reportedAt} ms after the report`)
|
||||
const { child, answers, unexpected } = outcome.reports[0]
|
||||
if (child.origin !== "null") fail("origin", `self.origin is ${JSON.stringify(child.origin)}`)
|
||||
if (child.parentDocument !== "blocked") fail("parent", `parent.document is ${child.parentDocument}`)
|
||||
if (child.context !== "native-session") fail("context", `context ${JSON.stringify(child.context)}`)
|
||||
if (child.errors.length) fail("api", `frame method errors: ${child.errors.join("; ")}`)
|
||||
if (unexpected.length) fail("api", `unexpected port messages: ${JSON.stringify(unexpected)}`)
|
||||
const expected = Object.entries(outcome.methods).flatMap(([object, names]) => names.map(method => `${object}.${method}`))
|
||||
const answered = answers.map(answer => `${answer.capability}.${answer.method}`)
|
||||
if (JSON.stringify(answered.toSorted()) !== JSON.stringify(expected.toSorted())) fail("api", `port answers ${JSON.stringify(answered)}, expected ${JSON.stringify(expected)}`)
|
||||
for (const key of expected) if (child.api[key]?.probe !== key) fail("api", `frame saw ${JSON.stringify(child.api[key])} for ${key}`)
|
||||
const surface = { apiVersion: "number", getContext: "function", onContext: "function" }
|
||||
const listeners = LISTENERS[outcome.apiVersion] ?? {}
|
||||
for (const object of new Set([...Object.keys(outcome.methods), ...Object.keys(listeners)])) surface[object] = [...(outcome.methods[object] ?? []), ...(listeners[object] ?? [])]
|
||||
const sorted = value => JSON.stringify(Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, Array.isArray(entry) ? entry.toSorted() : entry]).toSorted()))
|
||||
if (sorted(child.surface) !== sorted(surface)) fail("api", `frame surface ${JSON.stringify(child.surface)}, expected ${JSON.stringify(surface)}`)
|
||||
const change = outcome.apiVersion === 4 ? { gallery: true, status: false } : outcome.apiVersion === 2 ? "called" : null
|
||||
if (JSON.stringify(child.changed) !== JSON.stringify(change)) fail("api", `change notification ${JSON.stringify(child.changed)}`)
|
||||
return violations
|
||||
}
|
||||
|
||||
const NEGATIVE_CATEGORIES = { "same-origin": ["origin", "parent"], "no-csp": ["leak"] }
|
||||
|
||||
/** Runs every mode through `launch(url, mode)` (resolving to the host's output) and asserts real isolation plus detected negative controls. */
|
||||
export async function runProbe({ host, apiVersion, launch }) {
|
||||
const policy = await loadFramePolicy()
|
||||
const server = await startProbeServer({ policy, apiVersion })
|
||||
try {
|
||||
const summary = { host, apiVersion, sandbox: policy.PANEL_EXTENSION_SANDBOX }
|
||||
for (const mode of MODES) {
|
||||
const output = await launch(server.url(mode), mode)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
const outcome = server.outcome(mode, { output, exitedAt: Date.now() })
|
||||
const violations = probeViolations(outcome)
|
||||
const report = outcome.reports[0]
|
||||
const line = {
|
||||
origin: report?.child.origin, parentDocument: report?.child.parentDocument, positive: report?.positive,
|
||||
nativeLines: output.split(/\r?\n/).map(entry => entry.trim()).filter(entry => entry.startsWith("NATIVE:")),
|
||||
leakCount: outcome.leaks.length, answers: report?.answers.map(answer => `${answer.capability}.${answer.method}`),
|
||||
frameGlobals: report && Object.fromEntries(Object.entries(report.child.globals).filter(([, type]) => type !== "undefined")),
|
||||
violations: violations.map(violation => violation.category),
|
||||
}
|
||||
if (mode === "real") {
|
||||
assert.deepEqual(violations, [], `${host} real mode failed:\n${violations.map(violation => violation.message).join("\n")}\n${output}`)
|
||||
} else {
|
||||
const missing = NEGATIVE_CATEGORIES[mode].filter(category => !line.violations.includes(category))
|
||||
assert.equal(missing.length, 0, `${host} negative control ${mode} was not detected (${missing.join(", ")}): ${JSON.stringify(line)}\n${output}`)
|
||||
line.detected = NEGATIVE_CATEGORIES[mode]
|
||||
}
|
||||
summary[mode] = line
|
||||
}
|
||||
return summary
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,15 +7,15 @@ import assert from "node:assert/strict"
|
|||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdir, mkdtemp, readdir, readFile, realpath, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import readline from "node:readline"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { clearFixtureGitEnvironment, stopFixtureChild } from "./native-fixture-guards.mjs"
|
||||
import { stopFixtureChild } from "./native-fixture-guards.mjs"
|
||||
import { fixtureEnvironment, fixtureParent } from "./native-product-fixture.mjs"
|
||||
|
||||
const server = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "packages", "server", "src", "opencode")
|
||||
const load = file => tsImport(`../packages/server/src/opencode/${file}`, import.meta.url)
|
||||
|
|
@ -60,19 +60,14 @@ const sources = {
|
|||
apacheLicense: await readSource(DESIGN_SOURCE_FILES.apacheLicense),
|
||||
}
|
||||
const content = parseDesignContent(sources)
|
||||
const version = spawnSync(cli, ["--version"], { encoding: "utf8" }).stdout.trim()
|
||||
// Project discovery walks every ancestor directory: outside CI the fixture must live
|
||||
// outside the user's home, or it would read the user's own agents and skills.
|
||||
const requested = path.join(process.env.CODENOMAD_NATIVE_FIXTURE_PARENT || os.tmpdir(), "opencode")
|
||||
await mkdir(requested, { recursive: true })
|
||||
const parent = await realpath(requested)
|
||||
const relative = path.relative(await realpath(os.homedir()), parent)
|
||||
assert(process.env.CI || relative.startsWith("..") || path.isAbsolute(relative),
|
||||
"Project-config fixtures read every ancestor directory: set CODENOMAD_NATIVE_FIXTURE_PARENT outside your home")
|
||||
const root = await mkdtemp(path.join(parent, "design-activation-"))
|
||||
const root = await mkdtemp(path.join(await fixtureParent({ projectConfig: true }), "design-activation-"))
|
||||
const configDirectory = path.join(root, "config")
|
||||
const project = name => path.join(root, name)
|
||||
const gitEnv = clearFixtureGitEnvironment({ ...process.env })
|
||||
// Children (Git, the CLI, the daemon, the backends) get only allowlisted variables and a private profile.
|
||||
const childEnv = await fixtureEnvironment(root)
|
||||
const version = spawnSync(cli, ["--version"], { env: childEnv, encoding: "utf8" }).stdout.trim()
|
||||
const USER_AGENT = "USER-DESIGN-AGENT"
|
||||
const USER_SKILL = "USER-DESIGN-CORE"
|
||||
const files = {
|
||||
|
|
@ -85,7 +80,7 @@ const files = {
|
|||
for (const name of ["p1", "p2-ask", "p3-user", "p4-late"]) {
|
||||
await mkdir(project(name), { recursive: true })
|
||||
// A Git root stops OpenCode's upward project-configuration search.
|
||||
assert.equal(spawnSync("git", ["init", "-q", project(name)], { env: gitEnv }).status, 0)
|
||||
assert.equal(spawnSync("git", ["init", "-q", project(name)], { env: childEnv }).status, 0)
|
||||
}
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
|
|
@ -126,22 +121,16 @@ const provider = createServer(async (req, res) => {
|
|||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
const settings = { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" }
|
||||
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_|npm_config_)/i.test(key)))
|
||||
for (const key of Object.keys(env)) if (["programdata", "temp", "tmp", "appdata", "localappdata"].includes(key.toLowerCase())) delete env[key]
|
||||
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
const password = randomUUID()
|
||||
Object.assign(env, {
|
||||
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, ProgramData: path.join(root, "ProgramData"),
|
||||
TEMP: path.join(root, "tmp"), TMP: path.join(root, "tmp"), APPDATA: path.join(root, "AppData", "Roaming"), LOCALAPPDATA: path.join(root, "AppData", "Local"),
|
||||
// Only the fixture providers load, and the fake image model is named, so automatic selection can never pick another provider.
|
||||
const env = await fixtureEnvironment(root, {
|
||||
OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_DB: path.join(root, "fixture.db"), OPENCODE_SERVER_PASSWORD: password,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
npm_config_userconfig: path.join(root, ".npmrc"), npm_config_globalconfig: path.join(root, "global.npmrc"), npm_config_cache: path.join(root, "npm-cache"),
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", experimental: { image_generate: true }, providers: {
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", enabled_providers: ["fixture", "fiximg"], experimental: { image_generate: { model: "fiximg/image" } }, providers: {
|
||||
fixture: { package: "@opencode/ai/providers/openai-compatible", settings, models: { vision: { name: "Fixture", capabilities: { tools: true, input: ["text", "image/png"], output: ["text"] } } } },
|
||||
fiximg: { package: "@opencode/ai/providers/openai-compatible/images", settings, models: { image: { name: "Fixture image", type: "image", capabilities: { tools: false, input: ["text"], output: ["image"] } } } },
|
||||
} }),
|
||||
})
|
||||
for (const directory of [env.TEMP, env.ProgramData]) await mkdir(directory, { recursive: true })
|
||||
|
||||
let output = ""
|
||||
const daemon = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
||||
|
|
@ -177,13 +166,16 @@ try {
|
|||
}
|
||||
const located = route => async directory => (await api("GET", `${route}?location[directory]=${encodeURIComponent(directory)}`)).json?.data ?? []
|
||||
const agents = located("/api/agent"), skills = located("/api/skill"), plugins = located("/api/plugin")
|
||||
// Discovery sees only the fixture's loopback providers, whatever the caller's environment holds.
|
||||
results.models = (await located("/api/model")(project("p1"))).map(model => `${model.providerID}/${model.id}`)
|
||||
assert(results.models.every(ref => /^(fixture|fiximg)\//.test(ref)), `only fixture models are discovered: ${results.models}`)
|
||||
const designSkills = async directory => (await skills(directory)).filter(skill => skill.id.startsWith("design-"))
|
||||
const hasDesign = async directory => (await agents(directory)).some(agent => agent.id === "design")
|
||||
const allHave = async (names, wanted) => (await Promise.all(names.map(name => hasDesign(project(name))))).every(value => value === wanted)
|
||||
const loaded = ["p1", "p2-ask", "p3-user"]
|
||||
|
||||
async function startBackend(design) {
|
||||
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--backend", url, password, design ? "design" : "none"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true })
|
||||
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--backend", url, password, design ? "design" : "none"], { env: childEnv, stdio: ["pipe", "pipe", "pipe"], windowsHide: true })
|
||||
backends.add(child)
|
||||
const closed = new Promise(resolve => child.once("close", resolve))
|
||||
const lines = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator]()
|
||||
|
|
|
|||
337
scripts/test-design-companion-native.mjs
Normal file
337
scripts/test-design-companion-native.mjs
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// The Design companion end to end against an explicit isolated CLI: private home, config,
|
||||
// database, ProgramData and temp, loopback scripted chat and fake image models (no paid
|
||||
// call), the shipped session-pruning bundle requested by an in-process backend lease, and
|
||||
// only processes this script starts. The scripted model applies the Design rules (files under
|
||||
// `.codenomad/design/<subject>/`, one `read` per image, a refinement naming its parent); this
|
||||
// validates the wiring, never a real model's behaviour.
|
||||
// AC-16: 3 files, one `read` part per image, `images` gives the read images in 2 series,
|
||||
// proven, with lineage and pixels.
|
||||
// AC-52: a completed generation without read-back, its reference, the request sent, a read
|
||||
// more than 32 messages later, shown with its pixels and an unverifiable origin.
|
||||
// Usage: node scripts/test-design-companion-native.mjs <absolute opencode executable> [--ui <design ZIP>]
|
||||
// `--ui` plays the AC-52 request through the real panel, composer and transcript (AC-54).
|
||||
// Requires the session-pruning bundle (npm run build:pruning --workspace @neuralnomads/codenomad).
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { createHash, randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { crc32, deflateSync } from "node:zlib"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { clearFixtureGitEnvironment, stopFixtureChild } from "./native-fixture-guards.mjs"
|
||||
import { fixtureEnvironment, fixtureParent } from "./native-product-fixture.mjs"
|
||||
|
||||
const load = file => tsImport(`../packages/server/src/${file}`, import.meta.url)
|
||||
const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await load("opencode/desktop-plugin-lifecycle.ts")
|
||||
const { resolveDesktopPluginPaths } = await load("opencode/desktop-plugin-paths.ts")
|
||||
const { designLeaseContent } = await load("opencode/design/activation.ts")
|
||||
const { locationRequestOptions } = await load("opencode/compatibility/location.ts")
|
||||
const { imagesResultSchema } = await load("opencode/session-pruning/assets-gallery-contract.ts")
|
||||
|
||||
const cli = process.argv[2]
|
||||
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
||||
const uiIndex = process.argv.indexOf("--ui")
|
||||
const designZip = uiIndex > 0 ? process.argv[uiIndex + 1] : undefined
|
||||
assert(uiIndex < 0 || (designZip && path.isAbsolute(designZip)), "--ui takes the absolute path of the packaged Design ZIP")
|
||||
// Browser dependencies load before the native preamble (see test-session-pruning-ui.mjs).
|
||||
const companionUI = designZip ? await import("./test-design-companion-ui.mjs") : undefined
|
||||
await companionUI?.loadDesignCompanionUIDependencies()
|
||||
// Project discovery walks every ancestor directory: outside CI the fixture lives outside the home.
|
||||
const root = await mkdtemp(path.join(await fixtureParent({ projectConfig: true }), "design-companion-"))
|
||||
const configDirectory = path.join(root, "config")
|
||||
const project = path.join(root, "project")
|
||||
await mkdir(configDirectory, { recursive: true })
|
||||
await mkdir(project, { recursive: true })
|
||||
await writeFile(path.join(configDirectory, "opencode.json"), "{}\n")
|
||||
await writeFile(path.join(project, "README.md"), "Design companion fixture\n")
|
||||
// Children (Git, the CLI, the daemon) get only allowlisted variables and a private profile;
|
||||
// the in-process host routes' Git also runs without inherited GIT_* (a GIT_DIR or
|
||||
// GIT_WORK_TREE would send its writes outside the fixture).
|
||||
const childEnv = await fixtureEnvironment(root)
|
||||
clearFixtureGitEnvironment(process.env)
|
||||
const version = spawnSync(cli, ["--version"], { env: childEnv, encoding: "utf8" }).stdout.trim()
|
||||
const git = args => spawnSync("git", args, { cwd: project, env: childEnv, encoding: "utf8" })
|
||||
assert.equal(git(["init", "-q", project]).status, 0)
|
||||
const samePath = value => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value)
|
||||
assert.equal(samePath(git(["rev-parse", "--absolute-git-dir"]).stdout.trim()), samePath(path.join(await realpath(project), ".git")),
|
||||
"Git writes only the fixture's own repository")
|
||||
|
||||
// Distinct, decodable 16 × 16 PNGs, so each file's pixels identify its generation.
|
||||
function png() {
|
||||
const chunk = (type, data) => {
|
||||
const out = Buffer.alloc(12 + data.length)
|
||||
out.writeUInt32BE(data.length, 0); out.write(type, 4, "ascii"); data.copy(out, 8)
|
||||
out.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 8 + data.length)
|
||||
return out
|
||||
}
|
||||
const side = 16, header = Buffer.alloc(13)
|
||||
header.writeUInt32BE(side, 0); header.writeUInt32BE(side, 4); header[8] = 8; header[9] = 2
|
||||
const rows = Buffer.alloc((side * 3 + 1) * side)
|
||||
for (let y = 0; y < side; y++) for (let x = 0; x < side * 3; x++) rows[y * (side * 3 + 1) + 1 + x] = Math.floor(Math.random() * 256)
|
||||
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", deflateSync(rows)), chunk("IEND", Buffer.alloc(0))])
|
||||
}
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex")
|
||||
|
||||
// Scripted chat model. "RUN <json>" emits those tool calls in one response. A finished image
|
||||
// notice reads every saved, unread image in one response once all generations have their
|
||||
// notice (or only says "Noted." while `readBack` is off). The AC-52 request ("Read the
|
||||
// generated image `X`") takes PAD separate steps, then reads X, then answers with a relative
|
||||
// Markdown image of it.
|
||||
const PAD = 34
|
||||
const state = { readBack: true, imageCalls: [], chat: 0, providerError: undefined }
|
||||
const text = content => typeof content === "string" ? content : Array.isArray(content) ? content.map(part => part.text ?? "").join("\n") : ""
|
||||
const carriesImage = message => message.role === "user" && Array.isArray(message.content) && message.content.some(part => part.type === "image_url")
|
||||
const sse = (res, delta, finish) => {
|
||||
res.setHeader("content-type", "text/event-stream")
|
||||
for (const [d, f] of [[delta, null], [{}, finish]]) res.write(`data: ${JSON.stringify({ id: "f", object: "chat.completion.chunk", model: "fixture",
|
||||
choices: [{ index: 0, delta: d, finish_reason: f }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n`)
|
||||
res.end("data: [DONE]\n\n")
|
||||
}
|
||||
const say = (res, value) => sse(res, { role: "assistant", content: value }, "stop")
|
||||
const call = (res, calls) => sse(res, { role: "assistant", tool_calls: calls.map((entry, index) => ({ index, id: `call_${randomUUID()}`, type: "function",
|
||||
function: { name: entry.tool, arguments: JSON.stringify(entry.args) } })) }, "tool_calls")
|
||||
const relative = file => (path.isAbsolute(file) ? path.relative(project, file) : file).replaceAll("\\", "/")
|
||||
function respond(res, body) {
|
||||
const messages = body.messages ?? []
|
||||
if (!body.tools?.length) return say(res, "Images")
|
||||
const pathKey = name => {
|
||||
const tool = body.tools.find(entry => entry.function?.name === name)?.function
|
||||
assert(tool, `the ${name} tool is available`)
|
||||
return Object.keys(tool.parameters.properties).find(key => /^(path|file_?path)$/i.test(key))
|
||||
}
|
||||
const trigger = messages.findLastIndex(message => message.role === "user" && !carriesImage(message))
|
||||
const steps = messages.slice(trigger + 1).filter(message => message.role === "assistant" && message.tool_calls?.length).length
|
||||
const instruction = text(messages[trigger]?.content)
|
||||
const calls = messages.flatMap(message => message.tool_calls ?? []).map(entry => ({ name: entry.function?.name, args: JSON.parse(entry.function?.arguments || "{}") }))
|
||||
const run = /RUN (\[.*\])/s.exec(instruction)
|
||||
if (run) return steps ? say(res, "Started.") : call(res, JSON.parse(run[1]))
|
||||
// Reads name the absolute file, as the notice does; answers cite it as relative Markdown.
|
||||
const markdown = file => `})`
|
||||
const show = /Read the generated image `([^`]+)`/.exec(instruction)
|
||||
if (show) {
|
||||
if (steps < PAD) return call(res, [{ tool: "glob", args: { pattern: "*.md" } }])
|
||||
if (steps === PAD) return call(res, [{ tool: "read", args: { [pathKey("read")]: path.resolve(project, show[1]) } }])
|
||||
return say(res, `Here it is: ${markdown(show[1])}\n\nCritique: clean edges.`)
|
||||
}
|
||||
if (/Saved image: /.test(instruction)) {
|
||||
const answeredReads = messages.slice(trigger + 1).flatMap(message => message.tool_calls ?? []).filter(entry => entry.function?.name === "read")
|
||||
if (steps) return say(res, `Read back: ${answeredReads.map(entry => markdown(Object.values(JSON.parse(entry.function.arguments))[0])).join(" ")}`)
|
||||
const saved = [...messages.filter(message => message.role === "user").map(message => text(message.content)).join("\n").matchAll(/Saved image: (.+?) \(/g)].map(match => match[1])
|
||||
const read = new Set(calls.filter(entry => entry.name === "read").map(entry => path.resolve(project, Object.values(entry.args)[0] ?? "")))
|
||||
const unread = [...new Set(saved)].filter(file => !read.has(path.resolve(project, file)))
|
||||
const generations = calls.filter(entry => entry.name === "image_generate").length
|
||||
if (!state.readBack || saved.length < generations || !unread.length) return say(res, "Noted.")
|
||||
return call(res, unread.map(file => ({ tool: "read", args: { [pathKey("read")]: file } })))
|
||||
}
|
||||
return say(res, "FIXTURE_OK")
|
||||
}
|
||||
const provider = createServer(async (req, res) => {
|
||||
try {
|
||||
let raw = ""
|
||||
for await (const chunk of req) raw += chunk
|
||||
if (req.url.startsWith("/v1/images/")) {
|
||||
const bytes = png()
|
||||
state.imageCalls.push({ url: req.url, sha: sha(bytes), edit: req.url.includes("edits") })
|
||||
res.setHeader("content-type", "application/json")
|
||||
return res.end(JSON.stringify({ created: 1, data: [{ b64_json: bytes.toString("base64") }] }))
|
||||
}
|
||||
state.chat++
|
||||
respond(res, JSON.parse(raw))
|
||||
} catch (error) { state.providerError = error; res.destroy(error) }
|
||||
})
|
||||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
const settings = { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" }
|
||||
|
||||
const password = randomUUID()
|
||||
// Only the fixture providers load, and the fake image model is named, so automatic selection can never pick another provider.
|
||||
const env = await fixtureEnvironment(root, {
|
||||
OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_DB: path.join(root, "fixture.db"), OPENCODE_SERVER_PASSWORD: password,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "fixture/vision", small_model: "fixture/vision", enabled_providers: ["fixture", "fiximg"], experimental: { image_generate: { model: "fiximg/image" } }, providers: {
|
||||
fixture: { package: "@opencode/ai/providers/openai-compatible", settings, models: { vision: { name: "Fixture vision", capabilities: { tools: true, input: ["text", "image/png"], output: ["text"] } } } },
|
||||
fiximg: { package: "@opencode/ai/providers/openai-compatible/images", settings, models: { image: { name: "Fixture image", type: "image", capabilities: { tools: false, input: ["text", "image/png"], output: ["image"] } } } },
|
||||
} }),
|
||||
})
|
||||
|
||||
let output = ""
|
||||
const daemon = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
||||
const daemonStopped = new Promise(resolve => daemon.once("close", resolve))
|
||||
daemon.stdout.on("data", data => { output += data })
|
||||
daemon.stderr.on("data", data => { output += data })
|
||||
const lifecycle = new DesktopPluginLifecycle("session-pruning")
|
||||
const results = { version }
|
||||
let passed = false
|
||||
|
||||
async function until(label, predicate, ms = 60_000) {
|
||||
const end = Date.now() + ms
|
||||
for (;;) {
|
||||
if (state.providerError) throw state.providerError
|
||||
const value = await predicate()
|
||||
if (value) return value
|
||||
if (daemon.exitCode !== null) throw new Error(`Isolated CLI exited: ${output.slice(-3000)}`)
|
||||
if (Date.now() > end) throw new Error(`Timed out: ${label}`)
|
||||
await delay(150)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await until("daemon URL", () => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
||||
const url = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const client = OpenCode.make({ baseUrl: url, headers: { authorization } })
|
||||
const location = { directory: project }
|
||||
const locationOptions = locationRequestOptions(location)
|
||||
const rpc = async (method, input) => (await client.rpc.call({ rpcID: "codenomad.session-pruning", method, location, input }, locationOptions)).output
|
||||
await client.location.get({ location })
|
||||
// Discovery sees only the fixture's loopback providers, whatever the caller's environment holds.
|
||||
const models = async types => (await (await fetch(`${url}/api/model?${types ? `types=${types}&` : ""}location[directory]=${encodeURIComponent(project)}`,
|
||||
{ headers: { authorization }, signal: AbortSignal.timeout(30_000) })).json()).data.map(model => `${model.providerID}/${model.id}`)
|
||||
results.models = await models()
|
||||
assert(results.models.every(ref => /^(fixture|fiximg)\//.test(ref)), `only fixture models are discovered: ${results.models}`)
|
||||
assert.deepEqual(await models("image"), ["fiximg/image"])
|
||||
await lifecycle.setLeaseContent(designLeaseContent(true))
|
||||
await prepareDesktopPluginPresence(await resolveDesktopPluginPaths({ client, assertCurrent() {} }, { kind: "host", platform: process.platform, binary: "fixture" }),
|
||||
() => {}, { pruning: lifecycle })
|
||||
await until("Design agent and gallery RPC", async () => (await client.agent.list({ location })).data.some(agent => agent.id === "design")
|
||||
&& await rpc("imagesConversation", { mode: "lookup" }).then(answer => answer.status === "absent", () => false))
|
||||
|
||||
const opened = await rpc("imagesConversation", { mode: "open", title: "Images", agent: "design", model: { providerID: "fixture", id: "vision" } })
|
||||
assert.equal(opened.created, true, JSON.stringify(opened))
|
||||
const sessionID = opened.sessionID
|
||||
const messages = async () => {
|
||||
const { data } = await client.message.list({ sessionID, limit: 200, order: "asc" })
|
||||
assert(data.length < 200, "the whole fixture conversation fits one native page")
|
||||
return data
|
||||
}
|
||||
const notices = list => list.filter(message => message.type === "synthetic" && message.metadata?.source === "image_generate")
|
||||
const readParts = list => list.flatMap(message => message.type === "assistant"
|
||||
? message.content.filter(part => part.type === "tool" && part.name === "read" && part.state?.status === "completed").map(part => ({ message: message.id, part })) : [])
|
||||
const idle = async () => { await client.session.wait({ sessionID }, { signal: AbortSignal.timeout(60_000) }); await delay(300) }
|
||||
async function turn(prompt, settled, label) {
|
||||
await client.session.prompt({ sessionID, text: prompt })
|
||||
await until(label, async () => settled(await messages()))
|
||||
await idle()
|
||||
}
|
||||
// Every page, as the panel reads them; each answer is checked against the published contract.
|
||||
async function gallery() {
|
||||
const entries = []
|
||||
let cursor
|
||||
do {
|
||||
const page = await rpc("images", { sessionID, prompts: true, ...(cursor ? { cursor } : {}) })
|
||||
assert.equal(page.status, "page", JSON.stringify(page))
|
||||
imagesResultSchema.parse(page)
|
||||
entries.push(...page.entries)
|
||||
cursor = page.cursor
|
||||
} while (cursor)
|
||||
return entries
|
||||
}
|
||||
const pixels = async target => {
|
||||
const answer = await rpc("imageRead", { sessionID, target })
|
||||
assert.equal(answer.status, "asset", JSON.stringify(answer).slice(0, 300))
|
||||
return Buffer.from(answer.uri.slice(answer.uri.indexOf(",") + 1), "base64")
|
||||
}
|
||||
const design = path.join(project, ".codenomad", "design", "icon")
|
||||
const file = name => path.join(design, name)
|
||||
const gen = (prompt, name, extra = {}) => ({ tool: "image_generate", args: { prompt, path: `.codenomad/design/icon/${name}`, ...extra } })
|
||||
|
||||
// AC-16, series 1: two generations, both read back in one answer once both are saved.
|
||||
await turn(`RUN ${JSON.stringify([gen("Flat blue app icon", "icon-minimal-a.png"), gen("Bold orange app icon", "icon-bold-a.png")])}`,
|
||||
list => notices(list).length >= 2 && readParts(list).length >= 2, "series 1 generated and read")
|
||||
// Series 2: a refinement of icon-minimal-a, which names it as its parent.
|
||||
await turn(`RUN ${JSON.stringify([gen("Same icon, warmer", "icon-minimal-a2.png", { images: [".codenomad/design/icon/icon-minimal-a.png"] })])}`,
|
||||
list => notices(list).length >= 3 && readParts(list).length >= 3, "refinement generated and read")
|
||||
|
||||
const files = (await readdir(design)).sort()
|
||||
assert.deepEqual(files, ["icon-bold-a.png", "icon-minimal-a.png", "icon-minimal-a2.png"], "3 files under .codenomad/design/<subject>/")
|
||||
assert.equal(state.imageCalls.length, 3)
|
||||
assert.deepEqual(state.imageCalls.map(entry => entry.edit), [false, false, true], "the refinement sends its parent to the image model")
|
||||
const transcript = await messages()
|
||||
const reads = readParts(transcript)
|
||||
assert.equal(reads.length, 3, "one read part per image")
|
||||
for (const { part } of reads) assert.equal(part.state.content.filter(item => item.type === "file" && /^image\//.test(item.mime ?? "")).length, 1, JSON.stringify(part.state.content).slice(0, 300))
|
||||
assert.equal(new Set(reads.slice(0, 2).map(entry => entry.message)).size, 1, "the first two reads share one answer")
|
||||
let entries = await gallery()
|
||||
const images = entries.filter(entry => entry.kind === "image")
|
||||
const generations = entries.filter(entry => entry.kind === "generation")
|
||||
assert.equal(images.length, 3, JSON.stringify(entries))
|
||||
assert.equal(generations.length, 3)
|
||||
const byLabel = Object.fromEntries(images.map(entry => [entry.label, entry]))
|
||||
assert.deepEqual(Object.keys(byLabel).sort(), ["icon-bold-a", "icon-minimal-a", "icon-minimal-a2"])
|
||||
assert.equal(new Set(images.map(entry => entry.series)).size, 2, "two series")
|
||||
assert.equal(byLabel["icon-minimal-a"].series, byLabel["icon-bold-a"].series)
|
||||
assert.notEqual(byLabel["icon-minimal-a2"].series, byLabel["icon-minimal-a"].series)
|
||||
for (const entry of images) {
|
||||
assert.equal(entry.origin, "proven", JSON.stringify(entry))
|
||||
assert(generations.some(generation => generation.key === entry.generationKey && generation.tool === "completed" && generation.outcome === "completed"), JSON.stringify(entry))
|
||||
assert.equal(typeof entry.prompt, "string")
|
||||
assert.equal(entry.model, "fiximg/image", "the named fake image model generated it")
|
||||
const bytes = await pixels(entry.target)
|
||||
assert.deepEqual(bytes, await readFile(file(`${entry.label}.png`)), `${entry.label}: the gallery pixels are the file's`)
|
||||
assert(state.imageCalls.some(item => item.sha === sha(bytes)), `${entry.label}: the pixels come from the image model`)
|
||||
}
|
||||
assert.deepEqual([byLabel["icon-minimal-a"].prompt, byLabel["icon-bold-a"].prompt, byLabel["icon-minimal-a2"].prompt],
|
||||
["Flat blue app icon", "Bold orange app icon", "Same icon, warmer"])
|
||||
assert.deepEqual(byLabel["icon-minimal-a2"].parentKeys, [byLabel["icon-minimal-a"].generationKey], "lineage: the refinement's parent")
|
||||
assert.equal(byLabel["icon-minimal-a2"].otherRefs ?? 0, 0)
|
||||
assert.equal(byLabel["icon-minimal-a"].parentKeys?.length ?? 0, 0)
|
||||
const raw = JSON.stringify(entries)
|
||||
for (const leak of [project, project.replaceAll("\\", "/"), "ses_", "call_", "Saved image"]) assert(!raw.includes(leak), `the gallery never carries ${leak}`)
|
||||
results.ac16 = { files, series: 2, images: images.map(entry => ({ label: entry.label, origin: entry.origin, parents: entry.parentKeys?.length ?? 0 })) }
|
||||
console.log("PASS AC-16: 3 files under .codenomad/design/icon/, one read part per image, 2 series, proven, lineage and pixels")
|
||||
|
||||
// AC-52: a completed generation the agent does not read back.
|
||||
state.readBack = false
|
||||
const answered = (list, words) => list.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text.includes(words)))
|
||||
await turn(`RUN ${JSON.stringify([gen("Monochrome app icon", "icon-mono-a.png")])}`, list => notices(list).length >= 4, "unread generation")
|
||||
await until("the notice answered without a read", async () => {
|
||||
const list = await messages()
|
||||
return answered(list.slice(list.indexOf(notices(list)[3])), "Noted.")
|
||||
})
|
||||
await idle()
|
||||
entries = await gallery()
|
||||
const unread = entries.find(entry => entry.kind === "generation" && entry.prompt === "Monochrome app icon")
|
||||
assert(unread && unread.tool === "completed" && unread.outcome === "completed", JSON.stringify(unread))
|
||||
assert(!entries.some(entry => entry.kind === "image" && entry.generationKey === unread.key), "no read-back image is attached")
|
||||
assert.equal(entries.filter(entry => entry.kind === "image").length, 3)
|
||||
const reference = await rpc("imageReference", { sessionID, target: unread.target })
|
||||
assert.deepEqual(reference, { status: "path", path: ".codenomad/design/icon/icon-mono-a.png" })
|
||||
const generatedAt = (await messages()).findLastIndex(message => message.type === "assistant" && message.content.some(part => part.type === "tool" && part.name === "image_generate"))
|
||||
|
||||
if (companionUI) await companionUI.testDesignCompanionUI({ cli, client, url, password, root, location, sessionID, designZip, until, idle })
|
||||
else {
|
||||
// The Design extension's English request, as `composer.prefill` substitutes its reference.
|
||||
await turn(`Read the generated image \`${reference.path}\` and show it here with your critique.`,
|
||||
list => readParts(list).length >= 4 && answered(list, "Here it is"), "late read-back")
|
||||
}
|
||||
|
||||
const after = await messages()
|
||||
const lateRead = readParts(after).at(-1)
|
||||
const readAt = after.findIndex(message => message.id === lateRead.message)
|
||||
const between = after.slice(generatedAt + 1, readAt).filter(message => message.type === "assistant").length
|
||||
assert(between > 32, `the read comes ${between} assistant messages after its generation`)
|
||||
entries = await gallery()
|
||||
const late = entries.filter(entry => entry.kind === "image" && entry.label === "icon-mono-a")
|
||||
assert.equal(late.length, 1, JSON.stringify(entries.filter(entry => entry.kind === "image")))
|
||||
assert.equal(late[0].origin, "unverifiable", JSON.stringify(late[0]))
|
||||
for (const key of ["generationKey", "parentKeys", "prompt", "model"]) assert.equal(late[0][key], undefined, `an unverifiable image carries no ${key}`)
|
||||
assert.deepEqual(await pixels(late[0].target), await readFile(file("icon-mono-a.png")), "the late read shows the file's pixels")
|
||||
assert.deepEqual(await rpc("imageReference", { sessionID, target: late[0].target }), { status: "refused", reason: "unverifiable" })
|
||||
// The earlier series stay proven on older pages.
|
||||
assert.deepEqual(entries.filter(entry => entry.kind === "image" && entry.label !== "icon-mono-a").map(entry => entry.origin), ["proven", "proven", "proven"])
|
||||
results.ac52 = { assistantMessagesBetween: between, origin: late[0].origin, ui: Boolean(designZip) }
|
||||
console.log(`PASS AC-52${designZip ? " (native and browser)" : " (native)"}: no read-back, reference, request sent, read ${between} assistant messages later, pixels, unverifiable origin`)
|
||||
assert.equal(state.imageCalls.length, 4, "every image request reached the fake image provider")
|
||||
console.log(`PASS ${version}: ${JSON.stringify({ ...results, imageCalls: state.imageCalls.length, chatRequests: state.chat })}`)
|
||||
passed = true
|
||||
} finally {
|
||||
await lifecycle.stop()
|
||||
try { await stopFixtureChild(daemon, daemonStopped) } finally {
|
||||
provider.closeAllConnections()
|
||||
await new Promise(resolve => provider.close(resolve))
|
||||
if (passed && !process.env.CODENOMAD_KEEP_FIXTURE) await rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }).catch(() => {})
|
||||
else { await writeFile(path.join(root, "daemon.log"), output).catch(() => {}); console.log(`Isolated fixture retained: ${root}`) }
|
||||
}
|
||||
}
|
||||
206
scripts/test-design-companion-ui.mjs
Normal file
206
scripts/test-design-companion-ui.mjs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// Browser half of scripts/test-design-companion-native.mjs: the S8 path (AC-52) through the real
|
||||
// Design extension, right panel, API 4 routes and channel, composer and transcript, against the
|
||||
// isolated runtime; and the transcript's relative Markdown image of a read image (AC-54).
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import pino from "pino"
|
||||
import { loadPruningUIDependencies } from "./test-session-pruning-ui.mjs"
|
||||
import { assertFixtureEnvironment, fixtureEnvironment } from "./native-product-fixture.mjs"
|
||||
|
||||
const load = file => tsImport(`../packages/server/src/${file}`, import.meta.url)
|
||||
// Vite loads before the native preamble (see test-session-pruning-ui.mjs).
|
||||
export const loadDesignCompanionUIDependencies = () => loadPruningUIDependencies()
|
||||
|
||||
export async function testDesignCompanionUI({ cli, client, url, password, root, location, sessionID, designZip, until, idle }) {
|
||||
const { uiRoot, chromium, createServer, solid, Fastify } = await loadPruningUIDependencies()
|
||||
const { registerInstanceProxyRoutes } = await load("server/http-server.ts")
|
||||
const { OpenCodeCliService } = await load("workspaces/opencode-cli-service.ts")
|
||||
const { OpenCodeSharedService } = await load("workspaces/opencode-service.ts")
|
||||
const { PanelExtensionStore } = await load("panel-extensions/store.ts")
|
||||
const { readPanelExtensionArchive } = await load("panel-extensions/archive.ts")
|
||||
const { registerPanelExtensionRoutes } = await load("server/routes/panel-extensions.ts")
|
||||
const { registerPanelExtensionApi4Routes } = await load("server/routes/panel-extension-api4.ts")
|
||||
const { WorktreeDeletionFence } = await load("workspaces/worktree-session-evacuation.ts")
|
||||
const { PluginControls } = await load("opencode/plugin-controls.ts")
|
||||
const { ImageSettings } = await load("opencode/image-settings.ts")
|
||||
|
||||
const instanceId = "design-ui", directory = location.directory
|
||||
// The production shared-service connection; its lifecycle answers from the isolated daemon
|
||||
// and never runs a service command.
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const lifecycle = new OpenCodeCliService({ label: "Isolated Design companion", timeoutMs: 5000, command: args => ({ command: cli, args, options: {} }) },
|
||||
{ execFile: async (_file, args) => ({ stdout: args.join(" ") === "service get password" ? password : url, stderr: "" }) })
|
||||
const sharedService = new OpenCodeSharedService()
|
||||
await sharedService.client({ kind: "lifecycle", identity: "isolated-design-companion", lifecycle })
|
||||
const connection = await sharedService.acquire()
|
||||
const endpoint = connection.endpoint, runtimeFetch = connection.fetch
|
||||
const owns = candidate => path.resolve(candidate) === path.resolve(directory)
|
||||
const workspaceManager = {
|
||||
get: id => id === instanceId ? { id, path: directory } : undefined,
|
||||
getSharedServiceEndpoint: async () => endpoint, getSharedServiceConnection: async () => connection,
|
||||
getInstanceAuthorizationHeader: () => authorization,
|
||||
getServiceDirectory: () => directory, getServiceLocation: () => location, getWorktrees: async () => ({ worktrees: [] }),
|
||||
getSharedServiceClient: async () => connection.client, getSharedServiceFetch: async () => runtimeFetch, desktopPluginsReady: async () => {},
|
||||
// The snapshot sent to the isolated session: the fixture's own environment, never this process's.
|
||||
getSessionEnvironment: async () => {
|
||||
const snapshot = await (await load("workspaces/session-environment.ts")).sessionEnvironment({},
|
||||
{ environment: await fixtureEnvironment(path.join(root, "session-environment")) })
|
||||
assertFixtureEnvironment(snapshot)
|
||||
return snapshot
|
||||
},
|
||||
getWorktreeIdentityForPath: async (_id, candidate) => owns(candidate) ? "isolated-fixture" : undefined,
|
||||
getServiceDirectoryForPath: async (_id, candidate) => owns(candidate) ? directory : undefined,
|
||||
ownsDirectory: async (_id, candidate) => owns(candidate), ownsPath: async (_id, candidate) => owns(candidate),
|
||||
ownsLocation: async (id, candidate) => id === instanceId && owns(candidate.directory) && candidate.workspaceID === location.workspaceID,
|
||||
getServicePathStyle: () => process.platform === "win32" ? "win32" : "posix",
|
||||
getServiceWslDistro: () => undefined, getHostPathForServicePath: async (_id, value) => value,
|
||||
}
|
||||
const logger = pino({ level: process.env.CODENOMAD_FIXTURE_LOG_LEVEL || "silent" })
|
||||
const store = new PanelExtensionStore(path.join(root, "panel-extensions"))
|
||||
const installed = await readPanelExtensionArchive(await readFile(designZip))
|
||||
assert.equal(installed.manifest.id, "neuralnomads.design")
|
||||
await store.install(installed)
|
||||
await store.activate(installed.manifest.id, installed.digest, true)
|
||||
const worktreeDeletionFence = new WorktreeDeletionFence()
|
||||
const imageSettings = new ImageSettings(new PluginControls({ workspaceManager, worktreeDeletionFence, logger }), workspaceManager)
|
||||
const broker = Fastify()
|
||||
await broker.register(replyFrom)
|
||||
registerInstanceProxyRoutes(broker, { workspaceManager, worktreeDeletionFence: { enter: () => () => {} }, logger })
|
||||
registerPanelExtensionRoutes(broker, { store, workspaceManager })
|
||||
registerPanelExtensionApi4Routes(broker, { store, workspaceManager, worktreeDeletionFence, imageSettings })
|
||||
// Like production's multiplexed stream, the server owns the native subscriber.
|
||||
broker.get("/fixture-events", async (request, reply) => {
|
||||
const abort = new AbortController()
|
||||
reply.hijack()
|
||||
reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" })
|
||||
reply.raw.on("close", () => abort.abort())
|
||||
try {
|
||||
for await (const event of client.event.subscribe({ signal: abort.signal })) reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
} catch (error) { if (!abort.signal.aborted) reply.raw.destroy(error) }
|
||||
finally { reply.raw.end() }
|
||||
})
|
||||
await broker.listen({ host: "127.0.0.1", port: 0 })
|
||||
const brokerUrl = `http://127.0.0.1:${broker.server.address().port}`
|
||||
// An owned optimized-dependency cache, removed only once the server drained (dev-docs/BROWSER_TESTING.md).
|
||||
const { createFixtureCache } = await tsImport("../packages/ui/tests/browser/fixture-cache.ts", import.meta.url)
|
||||
const { createFixtureShutdown } = await tsImport("../packages/ui/tests/browser/fixture-shutdown.ts", import.meta.url)
|
||||
const cache = await createFixtureCache(), shutdown = createFixtureShutdown(cache)
|
||||
const server = await createServer({
|
||||
configFile: false, root: uiRoot, logLevel: "error", cacheDir: cache.cacheDir, plugins: [shutdown.plugin, solid(), {
|
||||
name: "design-companion-fixture",
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use("/fixture", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await vite.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/design-companion.tsx"></script></body></html>'))
|
||||
})
|
||||
},
|
||||
}], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: {
|
||||
[`/workspaces/${instanceId}/instance`]: { target: brokerUrl }, "/api/panel-extensions": { target: brokerUrl }, "/fixture-events": { target: brokerUrl },
|
||||
} },
|
||||
})
|
||||
shutdown.own(server)
|
||||
let browser, page
|
||||
const pageErrors = []
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 950 }, locale: "en-US" })
|
||||
page.on("pageerror", error => { pageErrors.push(error.message); console.error("UI error:", error.stack ?? error.message) })
|
||||
page.on("response", response => { if (response.status() >= 400) console.error("UI response", response.status(), response.request().method(), response.url()) })
|
||||
page.on("console", message => { if (message.type() === "error") console.error("UI console:", message.text().slice(0, 500)) })
|
||||
// The host's own event transport stays steady, as in the browser suite; native events
|
||||
// reach the page through the fixture stream only.
|
||||
await page.addInitScript(() => {
|
||||
const Native = window.EventSource
|
||||
window.EventSource = class extends Native {
|
||||
constructor(url, init) {
|
||||
if (String(url).includes("/fixture-events")) super(url, init)
|
||||
else return { readyState: 1, addEventListener() {}, removeEventListener() {}, close() {} }
|
||||
}
|
||||
}
|
||||
})
|
||||
// Every other host route answers empty, as in the browser suite.
|
||||
await page.route(url => url.pathname.startsWith("/api/") && !url.pathname.startsWith("/api/panel-extensions"),
|
||||
route => route.fulfill({ json: { entries: [], files: [], records: [], messages: [], data: [] } }))
|
||||
const before = (await client.message.list({ sessionID, limit: 200, order: "asc" })).data.length
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/fixture?session=${sessionID}&directory=${encodeURIComponent(directory)}`)
|
||||
await page.waitForFunction(() => Boolean(window.fixture?.eventsReady()), undefined, { timeout: 60_000 })
|
||||
await page.getByRole("tab", { name: "Design", exact: true }).click()
|
||||
const frame = page.frameLocator("iframe[title='Design']")
|
||||
|
||||
// S8: the completed generation without read-back, and its request prefilled, never sent.
|
||||
const unread = frame.locator('li.gen[data-state="no-read"]')
|
||||
await unread.getByText("No read-back image attached", { exact: true }).waitFor({ timeout: 30_000 })
|
||||
assert.equal(await frame.locator('.tile[data-origin="unverifiable"]').count(), 0)
|
||||
assert.equal(await frame.locator(".tile").count(), 3, "the three proven images are in the gallery")
|
||||
await unread.getByRole("button", { name: "Ask the agent to show it", exact: true }).click()
|
||||
const expected = "Read the generated image `.codenomad/design/icon/icon-mono-a.png` and show it here with your critique."
|
||||
await page.waitForFunction(text => window.fixture.snapshot().draft === text, expected, { timeout: 20_000 })
|
||||
await page.waitForFunction(() => window.fixture.snapshot().focused)
|
||||
assert.equal((await client.message.list({ sessionID, limit: 200, order: "asc" })).data.length, before, "prefilling sends nothing")
|
||||
await page.screenshot({ path: path.join(root, "ui-prefilled.png") })
|
||||
|
||||
// The user sends it from the real composer.
|
||||
await page.locator("button.send-button").click()
|
||||
await until("the scripted model's late read-back", async () => (await client.message.list({ sessionID, limit: 200, order: "asc" })).data
|
||||
.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text.includes("Here it is"))), 120_000)
|
||||
await idle()
|
||||
const sent = (await client.message.list({ sessionID, limit: 200, order: "asc" })).data.filter(message => message.type === "user")
|
||||
assert.equal(sent.at(-1)?.text ?? sent.at(-1)?.content?.find?.(part => part.type === "text")?.text, expected, "the prefilled request is what was sent")
|
||||
|
||||
// The late read appears in the panel with its pixels and an unverifiable origin.
|
||||
const fileUri = `data:image/png;base64,${(await readFile(path.join(directory, ".codenomad", "design", "icon", "icon-mono-a.png"))).toString("base64")}`
|
||||
const late = frame.locator('.tile[data-origin="unverifiable"]')
|
||||
await late.filter({ hasText: "icon-mono-a" }).waitFor({ timeout: 30_000 })
|
||||
assert.match(await late.textContent(), /origin not verifiable/)
|
||||
const preview = late.locator(".preview img")
|
||||
await preview.waitFor({ timeout: 20_000 })
|
||||
// Decoded in the image's own document (the frame's `blob:` URLs belong to its opaque origin).
|
||||
const samePixels = image => image.evaluate(async (element, file) => {
|
||||
const draw = async source => {
|
||||
const decoded = new Image(); decoded.src = source; await decoded.decode()
|
||||
const canvas = document.createElement("canvas"); canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight
|
||||
const context = canvas.getContext("2d"); context.drawImage(decoded, 0, 0)
|
||||
return { size: [canvas.width, canvas.height], data: Array.from(context.getImageData(0, 0, canvas.width, canvas.height).data) }
|
||||
}
|
||||
const [shown, expected] = [await draw(element.src), await draw(file)]
|
||||
return JSON.stringify(shown.size) === JSON.stringify(expected.size) && shown.data.every((value, index) => value === expected.data[index])
|
||||
}, fileUri)
|
||||
assert.equal(await samePixels(preview), true, "the panel shows the file's pixels")
|
||||
await late.click()
|
||||
const detail = frame.locator("#selection")
|
||||
await detail.getByText("origin not verifiable", { exact: true }).waitFor()
|
||||
assert.equal(await detail.locator("dd.prompt").count(), 0, "an unverifiable image shows no prompt")
|
||||
assert.doesNotMatch(await detail.textContent(), /Monochrome app icon/)
|
||||
await page.screenshot({ path: path.join(root, "ui-late-read.png") })
|
||||
console.log("PASS AC-52 browser: no read-back shown, request prefilled and sent from the composer, late read with pixels and unverifiable origin in the panel")
|
||||
|
||||
// AC-54: the answer's relative Markdown image shows the read image in the real transcript.
|
||||
const answer = page.locator(".markdown-local-image[data-local-image='.codenomad/design/icon/icon-mono-a.png']")
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector("img.markdown-local-image[data-local-image='.codenomad/design/icon/icon-mono-a.png']")
|
||||
return Boolean(image?.complete && image.naturalWidth === 16)
|
||||
}, undefined, { timeout: 30_000 })
|
||||
assert.equal(await answer.count(), 1)
|
||||
assert.equal(await samePixels(answer), true, "the transcript shows the read file's pixels")
|
||||
assert.equal(await page.locator("span.markdown-local-image").count(), 0, "no unresolved local-image label")
|
||||
await page.screenshot({ path: path.join(root, "ui-transcript.png") })
|
||||
console.log("PASS AC-54: relative Markdown  of a read image renders it in the real transcript")
|
||||
assert.deepEqual(pageErrors, [], "no browser errors")
|
||||
} catch (error) {
|
||||
if (page) {
|
||||
await page.screenshot({ path: path.join(root, "ui-failure.png"), fullPage: true }).catch(() => {})
|
||||
console.error("UI sessions:", JSON.stringify(await page.evaluate(() => window.fixture?.sessions?.()).catch(String)).slice(0, 2000))
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await server.close()
|
||||
await broker.close()
|
||||
await sharedService.shutdown()
|
||||
}
|
||||
}
|
||||
105
scripts/test-panel-extension-electron.mjs
Normal file
105
scripts/test-panel-extension-electron.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Hosts the frame probe in real Electron with the real preload and the webPreferences
|
||||
// of createWindow (read from main.ts). No CodeNomad backend, profile or daemon is involved.
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn } from "node:child_process"
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { runProbe } from "./panel-extension-native-probe.mjs"
|
||||
|
||||
const root = fileURLToPath(new URL("../", import.meta.url))
|
||||
const electronApp = path.join(root, "packages/electron-app")
|
||||
const require = createRequire(path.join(electronApp, "package.json"))
|
||||
const preload = path.join(electronApp, "electron/preload/index.cjs")
|
||||
const windowId = "panel-extension-probe"
|
||||
|
||||
function topLevelEntries(body) {
|
||||
const entries = []
|
||||
let current = "", depth = 0, quote = null
|
||||
for (const char of body) {
|
||||
if (quote) { if (char === quote) quote = null }
|
||||
else if (char === '"' || char === "'" || char === "`") quote = char
|
||||
else if ("([{".includes(char)) depth++
|
||||
else if (")]}".includes(char)) depth--
|
||||
if (char === "," && depth === 0 && !quote) { entries.push(current.trim()); current = "" } else current += char
|
||||
}
|
||||
return current.trim() ? [...entries, current.trim()] : entries
|
||||
}
|
||||
|
||||
// Evaluates createWindow's webPreferences entry by entry; any entry this test cannot
|
||||
// reproduce exactly fails instead of being dropped.
|
||||
function createWindowPreferences(source) {
|
||||
const start = source.indexOf("const createWindow = (")
|
||||
assert.notEqual(start, -1, "createWindow is no longer found in main.ts")
|
||||
const open = source.indexOf("{", source.indexOf("webPreferences: {", start))
|
||||
assert.ok(open > start, "createWindow no longer declares webPreferences")
|
||||
let depth = 0, close = open
|
||||
for (; close < source.length; close++) {
|
||||
if (source[close] === "{") depth++
|
||||
else if (source[close] === "}" && --depth === 0) break
|
||||
}
|
||||
const preferences = {}
|
||||
for (const entry of topLevelEntries(source.slice(open + 1, close))) {
|
||||
if (entry === "...(saved ? { zoomFactor: saved.zoomFactor } : {})") continue // persisted zoom only
|
||||
const [, key, value] = /^(\w+):\s*([\s\S]+)$/.exec(entry) ?? []
|
||||
if (value === "true" || value === "false") preferences[key] = value === "true"
|
||||
else if (value === "!isMac") preferences[key] = process.platform !== "darwin"
|
||||
else if (key === "preload" && value === "getPreloadPath()") preferences.preload = preload
|
||||
else if (key === "additionalArguments" && /^\[[\s\S]*\]$/.test(value)) {
|
||||
preferences.additionalArguments = topLevelEntries(value.slice(1, -1)).map(item => {
|
||||
if (/^"[^"]*"$/.test(item)) return JSON.parse(item)
|
||||
assert.match(item, /^`[^`$]*\$\{windowId\}[^`$]*`$/, `Unrecognized createWindow additional argument: ${item}`)
|
||||
return item.slice(1, -1).replace("${windowId}", windowId)
|
||||
})
|
||||
} else assert.fail(`Unrecognized createWindow webPreferences entry: ${entry}`)
|
||||
}
|
||||
assert.equal(preferences.preload, preload, "createWindow must load the real preload")
|
||||
return preferences
|
||||
}
|
||||
|
||||
const webPreferences = createWindowPreferences(await readFile(path.join(electronApp, "electron/main/main.ts"), "utf8"))
|
||||
const channels = [...new Set([...(await readFile(preload, "utf8")).matchAll(/ipcRenderer\.invoke\("([^"]+)"/g)].map(match => match[1]))]
|
||||
assert.ok(channels.includes("cli:getStatus"), "The preload no longer exposes the positive control")
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-panel-electron-"))
|
||||
try {
|
||||
const entry = path.join(directory, "fixture.cjs")
|
||||
// Every preload invoke channel reports whether the main frame or a subframe sent it.
|
||||
await writeFile(entry, `const { app, BrowserWindow, ipcMain } = require("electron")
|
||||
const config = JSON.parse(require("node:fs").readFileSync(process.argv[process.argv.indexOf("--probe-config") + 1], "utf8"))
|
||||
process.on("uncaughtException", error => { console.error(error); app.exit(1) })
|
||||
for (const channel of config.channels) ipcMain.handle(channel, event => {
|
||||
const main = event.sender.mainFrame, frame = event.senderFrame
|
||||
const fromMain = Boolean(frame) && frame.processId === main.processId && frame.routingId === main.routingId
|
||||
console.log(fromMain && channel === "cli:getStatus" ? "NATIVE:parent" : "NATIVE:" + (fromMain ? "parent-other:" : "child:") + channel)
|
||||
return null
|
||||
})
|
||||
void app.whenReady().then(() => {
|
||||
const window = new BrowserWindow({ show: false, webPreferences: config.webPreferences })
|
||||
void window.loadURL(config.url)
|
||||
setTimeout(() => app.exit(0), config.seconds * 1000)
|
||||
})
|
||||
`)
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.NODE_OPTIONS
|
||||
// Some Linux test hosts disable unprivileged user namespaces; opt-in only.
|
||||
const sandboxArgs = process.platform === "linux" && process.argv.includes("--no-sandbox") ? ["--no-sandbox"] : []
|
||||
const summary = await runProbe({ host: "Electron", apiVersion: 4, launch: async (url, mode) => {
|
||||
const config = path.join(directory, `${mode}.json`)
|
||||
await writeFile(config, JSON.stringify({ url, seconds: 8, channels, webPreferences }))
|
||||
const child = spawn(require("electron"), [entry, "--probe-config", config, `--user-data-dir=${path.join(directory, `profile-${mode}`)}`, ...sandboxArgs],
|
||||
{ env, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let output = ""
|
||||
child.stdout.on("data", chunk => { output += chunk })
|
||||
child.stderr.on("data", chunk => { output += chunk })
|
||||
const timeout = setTimeout(() => child.kill(), 60_000)
|
||||
const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("exit", resolve) }).finally(() => clearTimeout(timeout))
|
||||
assert.equal(status, 0, output)
|
||||
return output
|
||||
} })
|
||||
console.log(JSON.stringify({ webPreferences: { ...webPreferences, preload: path.relative(root, preload) }, ...summary }, null, 2))
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 })
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
// No CodeNomad backend/profile, shared daemon or desktop instance is involved.
|
||||
// Hosts the frame probe in the isolated Tauri fixture: WebView2 on Windows,
|
||||
// WKWebView on macOS, WebKitGTK on Linux.
|
||||
import assert from "node:assert/strict"
|
||||
import { createServer } from "node:http"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
import { runProbe } from "./panel-extension-native-probe.mjs"
|
||||
|
||||
assert.equal(process.platform, "win32", "Run the WebView2 regression on Windows")
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const workspace = path.join(root, "packages/tauri-app")
|
||||
const target = path.resolve(process.env.CARGO_TARGET_DIR || path.join(workspace, "target"))
|
||||
|
|
@ -16,72 +16,28 @@ const env = { ...process.env, CARGO_TARGET_DIR: target }
|
|||
delete env.NODE_OPTIONS
|
||||
delete env.CODENOMAD_NATIVE_PARENT
|
||||
const build = spawnSync("cargo", ["build", "-p", "codenomad-panel-extension-fixture"], {
|
||||
cwd: workspace, env, encoding: "utf8", timeout: 300_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
||||
cwd: workspace, env, encoding: "utf8", timeout: 1_800_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
||||
})
|
||||
assert.equal(build.error, undefined, String(build.error))
|
||||
assert.equal(build.status, 0, build.stdout + build.stderr)
|
||||
const { panelExtensionDocument } = await tsImport(new URL("../packages/ui/src/components/panel-extensions/frame-document.ts", import.meta.url).href, import.meta.url)
|
||||
const apiVersion = process.argv.includes("--api3") ? 3 : process.argv.includes("--api2") ? 2 : 1
|
||||
const document = panelExtensionDocument(`<h1>External panel</h1><p id="session"></p><script>
|
||||
const results = { parent: 'unknown', network: 'unknown', context: null, nativeGlobal: typeof window.__TAURI_INTERNALS__ };
|
||||
try { parent.document; results.parent = 'allowed'; } catch { results.parent = 'blocked'; }
|
||||
codenomad.onContext(context => { results.context = context.sessionId; document.querySelector('#session').textContent = context.sessionId; });
|
||||
if (codenomad.assets) codenomad.onContext(async () => { results.asset = (await codenomad.assets.list()).entries[0].name; });
|
||||
if (codenomad.workItems) codenomad.onContext(async () => { results.workItem = (await codenomad.workItems.list({ kind: 'issue' })).items[0].title; });
|
||||
fetch('/api/forbidden').then(() => results.network = 'allowed').catch(() => results.network = 'blocked');
|
||||
// On Windows Tauri globals may be injected. Their presence is not permission.
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
__TAURI_INTERNALS__.invoke('probe', { role: 'child' }).catch(() => {});
|
||||
__TAURI_INTERNALS__.ipc({ cmd: 'probe', callback: 1, error: 2, payload: { role: 'raw-child' } });
|
||||
const apiVersion = process.argv.includes("--api4") ? 4 : process.argv.includes("--api3") ? 3 : process.argv.includes("--api2") ? 2 : 1
|
||||
const executable = path.join(target, "debug", `codenomad-panel-extension-fixture${process.platform === "win32" ? ".exe" : ""}`)
|
||||
const engine = { win32: "WebView2", darwin: "WKWebView", linux: "WebKitGTK" }[process.platform] ?? process.platform
|
||||
const summary = await runProbe({ host: `Tauri/${engine}`, apiVersion, launch: async url => {
|
||||
const profile = await mkdtemp(path.join(os.tmpdir(), "opencode-panel-native-"))
|
||||
try {
|
||||
// One host per mode, alive long enough for the frame report and the leak grace period.
|
||||
const child = spawn(executable, [url, profile, "10"], { cwd: workspace, env, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let output = ""
|
||||
child.stdout.on("data", chunk => { output += chunk })
|
||||
child.stderr.on("data", chunk => { output += chunk })
|
||||
const timeout = setTimeout(() => child.kill(), 60_000)
|
||||
const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", resolve) }).finally(() => clearTimeout(timeout))
|
||||
assert.equal(status, 0, output)
|
||||
return output
|
||||
} finally {
|
||||
// WebView2 releases its child-process lock shortly after the host exits.
|
||||
await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 })
|
||||
}
|
||||
parent.postMessage({ cmd: 'probe', role: 'message-child' }, '*');
|
||||
setTimeout(() => parent.postMessage({ type: 'results', results }, '*'), 1500);
|
||||
</script>`, "fixture-handshake", apiVersion)
|
||||
const reports = []
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/report") {
|
||||
let body = ""
|
||||
request.on("data", chunk => { body += chunk })
|
||||
request.on("end", () => { reports.push(JSON.parse(body)); response.end("ok") })
|
||||
return
|
||||
}
|
||||
response.setHeader("Content-Type", "text/html")
|
||||
response.end(`<script>
|
||||
window.addEventListener('message', e => { if (e.data?.type === 'results') fetch('/report', { method: 'POST', body: JSON.stringify(e.data.results) }); });
|
||||
window.addEventListener('DOMContentLoaded', () => __TAURI_INTERNALS__.invoke('probe', { role: 'parent' }));
|
||||
function initialize(frame) {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = e => {
|
||||
if (e.data?.handshake === 'fixture-handshake') channel.port1.postMessage({ type: 'context', context: { apiVersion: ${apiVersion}, sessionId: 'native-session', locale: 'en', appearance: 'light' } });
|
||||
else if (e.data?.type === 'assets:request') channel.port1.postMessage({ type: 'assets:result', id: e.data.id, result: { entries: [{ name: 'Native asset' }], cursor: null } });
|
||||
else if (e.data?.type === 'workItems:request') channel.port1.postMessage({ type: 'workItems:result', id: e.data.id, result: { status: 'ok', items: [{ title: 'Native work item' }] } });
|
||||
};
|
||||
frame.contentWindow.postMessage({ type: 'codenomad:init' }, '*', [channel.port2]);
|
||||
}
|
||||
</script><iframe sandbox="allow-scripts" onload="initialize(this)" srcdoc="${document.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<")}"></iframe>`)
|
||||
})
|
||||
const profile = await mkdtemp(path.join(process.env.TEMP || os.tmpdir(), "opencode-panel-native-"))
|
||||
try {
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||
const child = spawn(path.join(target, "debug/codenomad-panel-extension-fixture.exe"), [
|
||||
`http://127.0.0.1:${server.address().port}`, profile,
|
||||
], { cwd: workspace, env, stdio: ["ignore", "pipe", "pipe"] })
|
||||
let output = ""
|
||||
child.stdout.on("data", chunk => { output += chunk })
|
||||
child.stderr.on("data", chunk => { output += chunk })
|
||||
const status = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", resolve) })
|
||||
assert.equal(status, 0, output)
|
||||
assert.match(output, /NATIVE:parent/, "Positive native control must succeed")
|
||||
assert.doesNotMatch(output, /NATIVE:(?:child|raw-child|message-child)/, "Author code must not reach native commands")
|
||||
assert.equal(reports.length, 1, JSON.stringify(reports))
|
||||
assert.equal(reports[0].parent, "blocked")
|
||||
assert.equal(reports[0].network, "blocked")
|
||||
assert.equal(reports[0].context, "native-session")
|
||||
if (apiVersion === 2) assert.equal(reports[0].asset, "Native asset")
|
||||
if (apiVersion === 3) assert.equal(reports[0].workItem, "Native work item")
|
||||
console.log(JSON.stringify({ nativeParent: "allowed", nativeChild: "blocked", ...reports[0] }, null, 2))
|
||||
} finally {
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
// WebView2 releases its child-process lock shortly after the host exits.
|
||||
await rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 })
|
||||
}
|
||||
} })
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue