CodeNomad/scripts/test-opencode-setup-desktop.mjs
Pascal André e76e4484ab
fix(opencode): ground compatibility retirement in native contracts
Replace the arbitrary 2.0.11 floor with the 2.0.7 native step-timestamp boundary, keeping 2.0.11 as the separately recommended qualification target. Unlisted, prerelease and custom runtimes negotiate authenticated API requirements; preserve complete CLI labels so unknown builds neither fail solely by name nor enable unintended automatic upgrades.

Finish retiring pre-2.0.4 location/worktree/credential/Form serialization and obsolete catalog events while preserving internal identity, import/cursor authorization, pruning claims and connection fences. Remove the unused global package-manager installer and clean generated server output before packaging so deleted modules cannot survive incremental builds.

Distinguish missing setup, technical incompatibility and optional updates in the shared UI, including minimum rationale and optional restart wording. Retain a usable daemon through older discovery CLIs, check reload capability independently, and suppress installation when bundled npm is unavailable.

Validated 622 server tests, 7 browser scenarios, real reducers and fixture guards, production native suites on 2.0.7 and 2.0.10, and synthetic 2.0.3-to-2.0.7 history migration. Independent gatekeeper re-review closed the production version-parser finding. CI now qualifies the source-defined minimum and latest; rebuilt desktop acceptance is recorded separately.
2026-09-20 23:46:54 +02:00

223 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Windows release acceptance. All CLI commands and app launches use fresh synthetic
// profiles. CDP is discovered from the host's native dynamic instrumentation.
import assert from "node:assert/strict"
import { spawn, execFileSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdtemp, mkdir, readFile, writeFile, readdir } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { fileURLToPath } from "node:url"
import { setTimeout as delay } from "node:timers/promises"
import { chromium } from "playwright"
import { createServer } from "node:net"
import { boundedFixtureOperation } from "./fixtures/wsl-fixture-bounds.mjs"
import { stopFixtureChild } from "./native-fixture-guards.mjs"
assert.equal(process.platform, "win32", "This fixture validates Windows desktop artifacts")
const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const root = await mkdtemp(path.join(os.tmpdir(), "opencode", "codenomad-setup-desktop-"))
console.log(`Evidence: ${root}`)
const requested = process.argv[2] ?? "both"
const bootstrapCli = process.argv[3]
assert.ok(bootstrapCli && path.isAbsolute(bootstrapCli), "Pass host (both/electron/tauri) and absolute fixture CLI used only to configure an isolated service port")
const oldDaemon = process.argv.includes("--old-daemon")
const compatibleDaemon = process.argv.includes("--compatible-daemon")
assert.ok(!(oldDaemon && compatibleDaemon), "Choose one daemon scenario")
const resumeFolder = process.argv.includes("--resume-folder")
const artifacts = {
electron: path.join(workspace, "packages/electron-app/release/win-unpacked/CodeNomad.exe"),
tauri: path.join(workspace, "packages/tauri-app/target/release/codenomad-tauri.exe"),
}
const results = []
async function until(fn, message, timeout = 90_000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) { const value = await boundedFixtureOperation(fn, deadline, message); if (value) return value; await delay(200) }
throw new Error(message)
}
async function findPort(directory) {
for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
const name = path.join(directory, entry.name)
if (entry.name === "DevToolsActivePort") return Number((await readFile(name, "utf8")).split(/\r?\n/)[0])
if (entry.isDirectory()) { const port = await findPort(name); if (port) return port }
}
}
for (const [host, executable] of Object.entries(artifacts)) {
if (requested !== "both" && requested !== host) continue
const profile = path.join(root, host)
await mkdir(profile)
const project = path.join(profile, "synthetic-project")
await mkdir(project)
const config = path.join(profile, "config.yaml")
await writeFile(config, JSON.stringify({ server: { opencodeBinary: "opencode2" }, ui: { locale: "en" } }))
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) =>
!/^(PATH|OPENCODE_.*|XDG_.*|CLI_.*|CODENOMAD_.*|NODE_.*|ELECTRON_.*|WEBVIEW2_.*|npm_.*|HOME|USERPROFILE|APPDATA|LOCALAPPDATA)$/i.test(key)))
Object.assign(env, {
HOME: profile, USERPROFILE: profile, APPDATA: path.join(profile, "AppData/Roaming"), LOCALAPPDATA: path.join(profile, "AppData/Local"),
XDG_CONFIG_HOME: path.join(profile, "xdg-config"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
// Native service start intentionally strips XDG_STATE_HOME. Match its
// synthetic HOME default so status/stop read the same isolated service record.
XDG_STATE_HOME: path.join(profile, ".local/state"), XDG_CACHE_HOME: path.join(profile, "xdg-cache"),
OPENCODE_TEST_HOME: profile, OPENCODE_CONFIG_DIR: path.join(profile, "opencode-config"),
OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_CONFIG_CONTENT: "{}", OPENCODE_DISABLE_MODELS_FETCH: "1",
CLI_CONFIG: config, CLI_LOG_LEVEL: "debug", ELECTRON_ENABLE_LOGGING: "1", PATH: `${process.env.SystemRoot}\\System32`,
npm_config_cache: path.join(profile, "npm-cache"), npm_config_userconfig: path.join(profile, "npmrc"),
})
for (const key of ["APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "OPENCODE_CONFIG_DIR"]) await mkdir(env[key], { recursive: true })
const reservation = createServer()
await new Promise(resolve => reservation.listen(0, "127.0.0.1", resolve))
const servicePort = reservation.address().port
await new Promise(resolve => reservation.close(resolve))
const record = { host, executable, profile, servicePort, oldDaemon, compatibleDaemon, sha256: createHash("sha256").update(await readFile(executable)).digest("hex"), checks: [] }
const resources = path.join(path.dirname(executable), "resources")
record.packagedFiles = {}
for (const relative of ["server/dist/opencode-update/service.js", "server/dist/workspaces/opencode-cli-service.js", "server/dist/workspaces/native-service-registration.js", "server/public/index.html", "node/win32-x64/node.exe", "node/win32-x64/node_modules/npm/package.json"]) {
record.packagedFiles[relative] = createHash("sha256").update(await readFile(path.join(resources, relative))).digest("hex")
}
for (const retired of ["opencode-update/legacy-package.js", "opencode/compatibility/requests.js", "opencode/compatibility/events.js"]) {
await assert.rejects(readFile(path.join(resources, "server/dist", retired)), { code: "ENOENT" }, "retired modules must not survive in desktop resources")
}
record.sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace, encoding: "utf8", timeout: 10_000 }).trim()
results.push(record)
let output = "", browser, page, cli, child, stopped, childError
const runCli = (binary, args, timeout = 30_000) => execFileSync(binary, args, { env, cwd: profile, encoding: "utf8", timeout, maxBuffer: 1024 * 1024 }).trim()
const daemonInfo = async () => {
const password = runCli(bootstrapCli, ["service", "get", "password"])
const response = await fetch(`http://127.0.0.1:${servicePort}/api/info`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` },
signal: AbortSignal.timeout(10_000), redirect: "error",
})
assert.equal(response.status, 200)
return response.json()
}
const statuses = []
const observerErrors = [], observerTasks = new Set()
const observe = observedPage => observedPage.on("response", response => {
if (!response.url().endsWith("/api/opencode/update") && !response.url().endsWith("/api/opencode/service")) return
const task = boundedFixtureOperation(async () => {
statuses.push({ url: response.url(), method: response.request().method(), request: response.request().postDataJSON(), status: response.status(), body: await response.json() })
}, Date.now() + 10_000, "setup response observation").catch(error => observerErrors.push(error.message)).finally(() => observerTasks.delete(task))
observerTasks.add(task)
})
try {
runCli(bootstrapCli, ["service", "set", "port", String(servicePort)])
if (oldDaemon || compatibleDaemon) {
runCli(bootstrapCli, ["service", "start"], 60_000)
record.oldVersion = runCli(bootstrapCli, ["--version"])
record.oldService = runCli(bootstrapCli, ["service", "status"])
assert.equal(record.oldService, `http://127.0.0.1:${servicePort}`)
if (compatibleDaemon) record.before = await daemonInfo()
}
child = spawn(executable, resumeFolder ? ["--folder", project] : [], { cwd: profile, env, stdio: ["ignore", "pipe", "pipe"] })
// Native-parent-started services may inherit a pipe after the desktop exits.
// Observe process exit separately; the isolated service is stopped below.
stopped = new Promise(resolve => child.once("exit", resolve))
child.on("error", error => { childError = error })
const append = chunk => { output = (output + chunk).slice(-4 * 1024 * 1024) }
child.stdout.on("data", append); child.stderr.on("data", append)
const port = await until(async () => {
if (childError) throw childError
if (child.exitCode !== null) throw new Error(`${host} exited ${child.exitCode}: ${output}`)
return await findPort(profile)
}, `${host}: dynamic CDP not found`)
browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`, { timeout: 30_000 })
page = await until(() => browser.contexts().flatMap(context => context.pages()).find(candidate => /^https?:\/\/127\.0\.0\.1/.test(candidate.url())), `${host}: application page not found`)
page.setDefaultTimeout(90_000)
observe(page)
for (const context of browser.contexts()) context.on("page", observe)
await page.getByRole("dialog").waitFor()
await page.getByText(/^(OpenCode is not installed\.|OpenCode n’est pas installé\.)$/).waitFor()
await page.screenshot({ path: path.join(profile, "01-missing.png") })
await page.getByText(/introduced the native step-start timestamp|horodatage natif de début d’étape/).waitFor()
record.checks.push("real missing setup screen with separate technical minimum and recommendation")
await page.getByRole("button", { name: /^(Close|Fermer)$/ }).click()
await page.locator('[role="status"] button').click()
await page.getByRole("dialog").waitFor()
record.checks.push("dismiss and persistent recovery reentry")
await page.getByRole("button", { name: /^(Install and start OpenCode|Installer et démarrer OpenCode)$/ }).click()
await until(async () => {
const versions = await readdir(path.join(profile, ".local/share/codenomad/opencode/selected")).catch(() => [])
if (!versions.length) return false
cli = path.join(profile, ".local/share/codenomad/opencode", versions[0], "node_modules/@opencode/cli/bin/opencode.exe")
return cli
}, `${host}: bundled npm install failed`, 300_000)
record.checks.push("bundled Node/npm installed real CLI with system Node and opencode absent from PATH")
if (oldDaemon) {
const restart = page.getByRole("button", { name: /^(Restart shared service|Redémarrer le service partagé)$/ })
await until(async () => await restart.isVisible() || statuses.some(item => item.url.endsWith("/api/opencode/service") && item.method === "POST"), "Old daemon recovery did not settle")
assert.ok(await restart.isVisible(), "Old running daemon was bypassed: service activation occurred before explicit restart")
await page.screenshot({ path: path.join(profile, "02-explicit-restart.png") })
assert.ok(!statuses.some(item => item.url.endsWith("/api/opencode/service") && item.method === "POST"), "install must not restart the old daemon")
record.checks.push("old running daemon detected after install, restart deferred for explicit action")
await restart.click()
}
if (compatibleDaemon) {
await until(() => statuses.some(item => item.url.endsWith("/api/opencode/service") && item.status === 200
&& item.body.serviceState === "restart_available"), "Compatible daemon did not reconnect without restart")
assert.ok(!statuses.some(item => item.request?.restart === true), "compatible daemon must not be restarted automatically")
await page.getByText(/You can keep using the running service and restart later|Vous pouvez continuer avec le service en cours/).waitFor()
record.after = await daemonInfo()
assert.equal(record.after.pid, record.before.pid)
assert.equal(record.after.version, record.before.version)
await page.screenshot({ path: path.join(profile, "02-optional-restart.png") })
await page.getByRole("button", { name: /^(Close|Fermer)$/ }).click()
record.checks.push("usable older daemon retained with identical PID/version; restart optional after installation")
}
await page.getByRole("dialog").waitFor({ state: "hidden", timeout: 120_000 })
await page.screenshot({ path: path.join(profile, "02-installed-connected.png") })
record.cliVersion = runCli(cli, ["--version"])
record.service = runCli(cli, ["service", "status"])
record.checks.push("native-parent service activation, supported daemon admission, setup closes")
if (resumeFolder) {
record.workspaces = await until(async () => {
const data = await page.evaluate(async () => (await fetch("/api/workspaces", { signal: AbortSignal.timeout(10_000) })).json())
return JSON.stringify(data).toLowerCase().includes("synthetic-project") && data
}, "Pending folder did not resume")
await page.screenshot({ path: path.join(profile, "03-folder-resumed.png") })
record.checks.push("pending native --folder intent resumed after setup")
} else {
assert.ok(!statuses.some(item => item.request?.reload), "configuration reload must never happen automatically")
await page.getByRole("button", { name: "OpenCode", exact: true }).click()
const preferences = await until(() => browser.contexts().flatMap(context => context.pages()).find(candidate => candidate.url().includes("preferences")), "Native preferences page did not open")
preferences.setDefaultTimeout(90_000)
const reload = preferences.getByRole("button", { name: /^(Reload OpenCode configuration|Recharger la configuration OpenCode)$/ })
await reload.waitFor()
await preferences.screenshot({ path: path.join(profile, "03-reload-warning.png") })
const [response] = await Promise.all([
preferences.waitForResponse(response => response.url().endsWith("/api/opencode/service") && response.request().postDataJSON()?.reload === true, { timeout: 60_000 }),
reload.click(),
])
assert.equal(response.status(), 200)
record.checks.push("native preferences explicit configuration reload, warning visible, authenticated reload succeeds; no automatic reload")
}
await boundedFixtureOperation(() => Promise.all(observerTasks), Date.now() + 12_000, "response observers")
assert.deepEqual(observerErrors, [])
record.pass = true
} catch (error) {
record.error = error.stack
if (page) {
await page.screenshot({ path: path.join(profile, "failure.png"), timeout: 5_000 }).catch(() => {})
record.body = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")
}
console.error(`${host}: ${error.message}`)
} finally {
const cleanupErrors = []
if (browser) await boundedFixtureOperation(() => browser.close(), Date.now() + 5_000, "CDP cleanup").catch(error => cleanupErrors.push(error.message))
// Kill only the exact fixture PID and its descendants, never installed hosts.
if (child?.pid && child.exitCode === null) {
try { execFileSync(path.join(process.env.SystemRoot, "System32/taskkill.exe"), ["/pid", String(child.pid), "/t", "/f"], { stdio: "pipe", timeout: 10_000 }) }
// taskkill can report a child that exited during its tree walk. The
// bounded exit check below determines whether our desktop actually exited.
catch (error) { record.taskkillNotice = error.message }
await stopFixtureChild(child, stopped).catch(error => cleanupErrors.push(error.message))
}
try { record.cleanup = runCli(cli ?? bootstrapCli, ["service", "stop"]) } catch (error) { cleanupErrors.push(error.message) }
child?.stdout?.destroy(); child?.stderr?.destroy()
await boundedFixtureOperation(() => Promise.all(observerTasks), Date.now() + 12_000, "final response observers").catch(error => observerErrors.push(error.message))
if (cleanupErrors.length || observerErrors.length) { record.pass = false; record.cleanupErrors = cleanupErrors; record.observerErrors = observerErrors }
await writeFile(path.join(profile, "host.log"), output)
await writeFile(path.join(profile, "setup-responses.json"), JSON.stringify(statuses, null, 2))
await writeFile(path.join(root, "results.json"), JSON.stringify(results, null, 2))
}
}
console.log(JSON.stringify(results, null, 2))
assert.ok(results.length && results.every(result => result.pass), `Desktop acceptance failed; evidence: ${root}`)