mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 13:54:39 +00:00
## Summary Retire the prerelease private OpenCode installation completely as an executable source. Automatic selection now uses PATH and the conventional shared user npm installation. Existing private files are neither migrated nor deleted. - Remove the private installer, receipt reader and fallback introduced/retained by #756. - Ignore saved private selections and reject validation/launch from `~/.local/share/codenomad/opencode`, including private PATH entries. Prevent installation into that retired prefix. - Remove private-copy descriptions from all ten locales and update the runtime policy documentation. - Keep supported custom installations, native shared-service ownership/authentication and explicit restart behavior. ## Reproduction and scope The initial genuinely empty-profile test passed, but it did not match the affected machine: that machine had no Git and retained a private prerelease OpenCode copy. On the exact installed `v0.20.0-dev-20260923-baf674fc` Tauri artifact, seeding a valid private copy plus its `selected` receipt and `current` marker makes setup report `state: ready`, `installationSource: legacy`, and suppresses the missing-installation screen. The new desktop regression fails against that release with `ready !== missing`. With rebuilt server/UI resources in an isolated copy of the same Tauri host, the identical private-copy scenario reports missing, offers installation, installs the shared npm package, authenticates OpenCode 2.0.15 and resumes the requested folder without Git. This demonstrates the missing-setup cause; it does not claim to explain every initially reported startup slowdown. ## Validation - 59 targeted server tests passed: discovery/install/update, retired paths, binary selection, Windows/WSL spawn and binary validation routes. - 13 real-component setup browser tests passed. - 23 focused retirement/launch tests passed after the final network-path preflight adjustment. - UI TypeScript check passed; production server/UI/automation/pruning build passed. - Installed-release private-copy reproduction failed as expected; corrected isolated Tauri acceptance passed. - `git diff --check` passed. Desktop qualification uses isolated profiles, services and databases. The sole simulated installation side effect is HKCU PATH registration, captured by a fixture preload; bundled npm installation, package verification, native-parent service start and authenticated API checks are real. The shared user daemon and installed application were not replaced by the fix. Remote CI is pending. ## Maintenance notes Existing oversized files touched: `packages/server/src/api-types.ts` (~583 lines) and `packages/ui/src/lib/i18n/messages/*/settings.ts` (~625–639 lines). No size-only refactor is included.
247 lines
17 KiB
JavaScript
247 lines
17 KiB
JavaScript
// 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, copyFile } 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 privateCopy = process.argv.includes("--private-copy")
|
||
const artifacts = {
|
||
electron: path.join(workspace, "packages/electron-app/release/win-unpacked/CodeNomad.exe"),
|
||
tauri: process.env.CODENOMAD_FIXTURE_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"),
|
||
NODE_OPTIONS: `--require="${path.join(workspace, "scripts/fixtures/setup-path-registration.cjs").replaceAll("\\", "/")}"`,
|
||
})
|
||
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 })
|
||
if (privateCopy) {
|
||
const retiredRoot = path.join(profile, ".local/share/codenomad/opencode")
|
||
const retiredBinary = path.join(retiredRoot, "2.0.15/node_modules/@opencode/cli/bin/opencode.exe")
|
||
await mkdir(path.dirname(retiredBinary), { recursive: true })
|
||
await copyFile(bootstrapCli, retiredBinary)
|
||
await mkdir(path.join(retiredRoot, "selected"))
|
||
await writeFile(path.join(retiredRoot, "selected/2.0.15"), "")
|
||
await writeFile(path.join(retiredRoot, "current"), "2.0.15")
|
||
}
|
||
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, privateCopy, 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)
|
||
if (privateCopy) {
|
||
record.initialStatus = await page.evaluate(async () => (await fetch("/api/opencode/update", { signal: AbortSignal.timeout(30_000) })).json())
|
||
assert.equal(record.initialStatus.state, "missing", "retired private installation must not satisfy startup discovery")
|
||
assert.equal(record.initialStatus.binaryPath, "opencode2")
|
||
record.checks.push("retired private copy ignored, including selected receipt and current marker")
|
||
}
|
||
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(/^(Version details|Détails des versions)$/).click()
|
||
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 registration = await readFile(path.join(profile, "path-registration.json"), "utf8").catch(() => undefined)
|
||
if (!registration) return false
|
||
const { prefix } = JSON.parse(registration)
|
||
assert.equal(prefix, path.join(env.APPDATA, "npm"))
|
||
cli = path.join(prefix, "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"])
|
||
// Some CLI builds report stopped for a reachable daemon. Authenticate the
|
||
// actual isolated endpoint instead of accepting either status text alone.
|
||
record.connectedDaemon = await daemonInfo()
|
||
assert.ok(record.connectedDaemon.version)
|
||
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}`)
|