CodeNomad/packages/ui/tests/browser/opencode-setup.test.ts
Pascal André 9f93729882
fix(opencode): explain why OpenCode 2 cannot be installed beside an older command (#1093)
## Problem

On an Ubuntu 24.04 VM, OpenCode 1.17.18 was installed by curl in
`~/.opencode/bin` and also linked as `~/.local/bin/opencode`. With that
setup, the OpenCode panel offered **no installation**, only the generic
"Select a supported executable or install @opencode/cli on the execution
host" advice.

On macOS/Linux the user npm prefix is `~/.local`, so that link occupies
OpenCode's command name exactly where OpenCode 2 would be installed
beside the older version. npm refuses to link over it, and CodeNomad
never modifies a foreign installation. The plan therefore correctly had
no prefix, but nothing told the user why.

## Fix

`sharedInstallationPlan` reports the obstacle on the foreign
installation, forwarded as `foreignInstallation.blocked`:

| `reason` | `path` | When |
|---|---|---|
| `occupied` | the occupying command | a non-npm command holds
OpenCode's name in the user npm bin directory |
| `unwritable` | the user npm prefix | the prefix, or a recognized npm
installation in it (an `@opencode/cli` launcher, a Windows npm shim or
an `opencode-ai` 1.x installation), cannot be written |

The installation notice then says how to clear it: move or rename that
command, or fix the folder's permissions, then choose **Check status and
updates**. The usual curl layout, on PATH only through
`~/.opencode/bin`, still offers **Install OpenCode 2 for CodeNomad**.
This was verified on the same VM, where the installation then succeeded.

The English messages and their nine translations, the `opencode.md` help
page in all ten locales and `dev-docs/OPENCODE_RUNTIME_SETUP.md`
describe the obstacle.

## Tests

- `legacy-installation.test.ts`: builds the exact VM layout (link in the
user npm bin directory) and checks that the plan never offers a prefix
over the foreign command and reports `occupied`. The ordinary layout
must still offer the prefix, and a read-only prefix must report
`unwritable` (POSIX, non-root).
- `opencode-setup.test.ts`: renders the occupied notice with no install
action.
- A second POSIX regression puts a verified npm OpenCode 1.x
installation in a read-only user prefix and expects `unwritable`, never
`occupied`.
- Results:
- the `opencode-update` unit tests: 51/51 on Windows (1 POSIX-only
skipped); on the Ubuntu VM as uid 1000, 48 passed, 0 failed, 4
Windows-only skipped, including both POSIX cases;
  - the `opencode-setup` browser tests: 34/34;
  - the server and UI typechecks;
  - `npm run test:help`.
2026-10-22 13:49:20 +02:00

1103 lines
70 KiB
TypeScript
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.

import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import path from "node:path"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
cacheDir: "node_modules/.vite-opencode-setup",
plugins: [solid(), { name: "setup-fixture", configureServer(s) {
s.middlewares.use("/fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/opencode-setup.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
for (const surface of ["recovery", "settings"]) test(`${surface} shows sustained install progress through checking and connection`, async () => {
const page = await browser.newPage()
let finishInstall!: () => void, finishCheck!: () => void, finishConnect!: () => void
const installGate = new Promise<void>(resolve => { finishInstall = resolve })
const checkGate = new Promise<void>(resolve => { finishCheck = resolve })
const connectGate = new Promise<void>(resolve => { finishConnect = resolve })
let installed = false, connected = false, installs = 0, connects = 0
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
installs++
await installGate
installed = true
return route.fulfill({ json: { success: true, version: "2.0.17" } })
}
if (request.url().endsWith("/api/opencode/service")) {
connects++
assert.equal(request.postDataJSON().restart, false)
await connectGate
connected = true
} else if (installed) await checkGate
return route.fulfill({ json: { state: installed ? "ready" : "missing", currentVersion: installed ? "2.0.17" : null,
latestVersion: "2.0.17", minimumVersion: "2.0.7", recommendedVersion: "2.0.16", binaryPath: "opencode2", target: "host",
canUpgrade: !installed, updateAvailable: !installed, serviceState: connected ? "ready" : "stopped", canRestart: false } })
})
try {
await page.clock.install()
await page.goto(`${url}${surface === "settings" ? "?settings" : ""}`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByRole("button", { name: "Install and start OpenCode" }).click()
const progress = page.locator(".opencode-setup-progress")
await progress.getByText("Installing OpenCode…", { exact: true }).waitFor()
assert.equal(await progress.getByText(/Keep CodeNomad open/).count(), 1)
assert.equal(await progress.locator("svg.animate-spin").count(), 1)
assert.equal(await progress.evaluate(el => el.closest('[aria-busy="true"]') === null), true, "live progress must not be suppressed by a busy ancestor")
await page.clock.fastForward(65_000)
await progress.getByRole("timer").filter({ hasText: "1:05" }).waitFor()
assert.equal(await page.getByRole("button", { name: "Updating OpenCode...", exact: true }).isDisabled(), true)
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, `opencode-install-${surface}.png`) })
if (surface === "recovery") {
await page.getByRole("button", { name: "Close", exact: true }).click()
await page.locator(".opencode-setup-reminder").getByText("Installing OpenCode…", { exact: true }).waitFor()
await page.getByRole("button", { name: "OpenCode setup required" }).click()
await page.getByRole("dialog").getByRole("timer").filter({ hasText: "1:05" }).waitFor()
}
finishInstall()
await progress.getByText("Reading installed version...", { exact: true }).waitFor()
finishCheck()
await progress.getByText("Connecting to OpenCode…", { exact: true }).waitFor()
assert.equal(await progress.getByText(/Keep CodeNomad open/).count(), 1)
if (surface === "recovery") {
await page.getByRole("button", { name: "Close", exact: true }).click()
await page.locator(".opencode-setup-reminder").getByText("Connecting to OpenCode…", { exact: true }).waitFor()
await page.getByRole("button", { name: "OpenCode setup required" }).click()
}
finishConnect()
await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress"))
assert.equal(installs, 1, "closing/reopening must not retry installation")
assert.equal(connects, 1)
assert.deepEqual(errors, [])
} finally {
finishInstall(); finishCheck(); finishConnect()
await page.close()
}
})
test("missing installation and incompatible daemon expose different actions; restart is explicit", async () => {
const page = await browser.newPage()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
let installed = false, running = false, restarts = 0, installs = 0
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
installed = true; installs++
return route.fulfill({ json: { success: true, version: "2.0.11" } })
}
if (request.url().endsWith("/api/opencode/service")) {
assert.equal(request.postDataJSON().restart, true)
restarts++; running = true
}
return route.fulfill({ json: { state: installed ? "ready" : "missing", currentVersion: installed ? "2.0.11" : null,
latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host",
canUpgrade: !installed, updateAvailable: !installed, daemonVersion: running ? "2.0.11" : "2.0.6",
serviceState: installed ? running ? "ready" : "restart_required" : undefined, canRestart: installed && !running } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
const installBounds = await page.getByRole("button", { name: "Install and start OpenCode" }).boundingBox()
const selectorBounds = await page.locator(".selector-input").boundingBox()
assert.ok(installBounds && selectorBounds && installBounds.y < selectorBounds.y, "recovery presents diagnosis and installation before executable selection")
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-missing.png"), fullPage: true })
await page.getByRole("button", { name: "Install and start OpenCode" }).click()
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-restart.png") })
assert.equal(installs, 1)
assert.equal(restarts, 0)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.getByText(/Restart interrupts active work/).waitFor()
await page.getByRole("button", { name: "Restart shared service" }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(restarts, 1)
assert.equal(await page.getByRole("dialog").count(), 0)
assert.deepEqual(errors, [])
} catch (error) { console.error(errors, await page.locator("body").innerText()); throw error }
finally { await page.close() }
})
test("closing required-update screen leaves recovery clickable above a pending folder overlay", async () => {
const page = await browser.newPage()
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.32",
latestVersion: "2.1.2", minimumVersion: "2.1.2", recommendedVersion: "2.1.2", incompatibilityReason: "native_contract", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false } }))
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByRole("dialog").waitFor()
await page.evaluate(() => {
const overlay = document.createElement("div")
overlay.className = "folder-loading-overlay"
document.getElementById("root")!.append(overlay)
})
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-required.png") })
await page.getByRole("button", { name: "Close", exact: true }).click()
await page.getByRole("button", { name: "OpenCode setup required" }).click()
await page.getByRole("dialog").waitFor()
assert.equal(await page.getByText("2.0.32", { exact: true }).count(), 1)
assert.equal(await page.locator("details[open]").count(), 0)
await page.getByText("Version details", { exact: true }).click()
await page.getByText(/requires OpenCode 2\.1\.2 or later for correct JavaScript job ordering in Code Mode/).waitFor()
assert.equal(await page.getByText("settings.opencode.setup.native_contract", { exact: true }).count(), 0)
} catch (error) { console.error(await page.locator("body").innerText()); throw error }
finally { await page.close() }
})
test("optional updates stay non-blocking and a failed install remains recoverable", async () => {
const page = await browser.newPage()
let attempts = 0, starts = 0
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
attempts++
return route.fulfill({ status: 500, json: { error: "upgrade_failed" } })
}
if (request.url().endsWith("/api/opencode/service")) starts++
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.10", latestVersion: "2.0.11",
minimumVersion: "2.0.7", recommendedVersion: "2.0.11", versionAssessment: "untested", binaryPath: "opencode2", target: "host", canUpgrade: true, canRestart: false,
serviceState: "ready", daemonVersion: "2.0.10" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
assert.equal(await page.getByRole("dialog").count(), 0)
await page.evaluate(() => (window as any).fixture.open())
await page.getByText(/OpenCode 2.0.10 has not been fully validated/).waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-setup-optional.png") })
await page.getByRole("button", { name: "Update to OpenCode 2.0.11" }).click()
await page.locator(".settings-error-message").waitFor()
assert.equal(starts, 0)
assert.equal(attempts, 1)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.getByRole("button", { name: "Check status and updates" }).click()
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(await page.locator(".settings-error-message").count(), 1, "a successful status read must not erase the install failure")
await page.getByRole("button", { name: "Close", exact: true }).click()
await page.evaluate(() => (window as any).fixture.open())
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(await page.locator(".settings-error-message").count(), 1, "reopening recovery preserves the failed action")
await page.getByRole("button", { name: "Update to OpenCode 2.0.11" }).click()
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(attempts, 2)
} finally { await page.close() }
})
test("unsupported proxy calls open recovery once without replaying mutations", async () => {
const page = await browser.newPage()
let blocked = false, prompts = 0
await page.route("**/api/**", route => {
if (route.request().url().includes("/instance/api/")) {
prompts++; blocked = true
return route.fulfill({ status: 426, json: { code: "opencode_update_required" } })
}
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11",
minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: false,
serviceState: blocked ? "restart_required" : "ready", canRestart: blocked, daemonVersion: blocked ? "2.0.6" : "2.0.11" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
await page.getByRole("button", { name: "Close", exact: true }).click()
assert.equal(await page.evaluate(() => (window as any).fixture.unsupported()), 426)
assert.equal(await page.getByRole("dialog").count(), 0)
assert.equal(prompts, 2, "each intentional call occurs once; recovery never retries a prompt")
} finally { await page.close() }
})
test("optional install reconnects without restarting and leaves explicit activation available", async () => {
const page = await browser.newPage()
let installed = false, restarts = 0, connects = 0
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
installed = true
return route.fulfill({ json: { success: true, version: "2.0.12" } })
}
if (request.url().endsWith("/api/opencode/service")) {
if (request.postDataJSON().restart) restarts++
else connects++
}
return route.fulfill({ json: { state: "ready", currentVersion: installed ? "2.0.12" : "2.0.11",
latestVersion: "2.0.12", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host",
canUpgrade: !installed, updateAvailable: !installed, daemonVersion: restarts ? "2.0.12" : "2.0.11",
serviceState: installed && !restarts ? "restart_available" : "ready", canRestart: installed && !restarts } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("button", { name: "Update to OpenCode 2.0.12" }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(connects, 1, "rebind backend authority while retaining the supported daemon")
assert.equal(restarts, 0)
await page.getByRole("button", { name: "Restart shared service" }).waitFor()
await page.getByText(/You can keep using the running service and restart later/).waitFor()
assert.equal(await page.getByText(/The running service needs updating/).count(), 0)
await page.getByRole("button", { name: "Close", exact: true }).click()
assert.equal(await page.getByRole("button", { name: "OpenCode setup required" }).count(), 0)
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("button", { name: "Restart shared service" }).click()
await page.waitForFunction(() => !document.querySelector('[role="dialog"]'))
assert.equal(restarts, 1)
} finally { await page.close() }
})
test("configuration reload is an informed explicit action and never runs on setup reads", async () => {
const page = await browser.newPage()
let reloads = 0
await page.route("**/api/**", route => {
if (route.request().url().endsWith("/api/opencode/service")) {
assert.deepEqual(route.request().postDataJSON(), { reload: true })
reloads++
}
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11",
minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "wsl", canUpgrade: false,
serviceState: "ready", canRestart: false, canReload: true, daemonVersion: "2.0.11" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
await page.getByText("Troubleshooting", { exact: true }).click()
await page.getByText(/cancels pending permissions and forms/).waitFor()
assert.equal(reloads, 0)
await page.getByRole("button", { name: "Reload OpenCode configuration" }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(reloads, 1)
assert.equal(await page.getByRole("dialog").count(), 0)
} finally { await page.close() }
})
test("activation failure after installation retains the installed version and retries connection", async () => {
const page = await browser.newPage()
let installed = false, connected = false, installations = 0, attempts = 0
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
installed = true; installations++
return route.fulfill({ json: { success: true, version: "2.0.11" } })
}
if (request.url().endsWith("/api/opencode/service")) {
attempts++
if (attempts === 1) return route.fulfill({ status: 502, json: { error: "service_activation_failed" } })
connected = true
}
return route.fulfill({ json: { state: installed ? "ready" : "missing", currentVersion: installed ? "2.0.11" : null,
latestVersion: "2.0.11", minimumVersion: "2.0.7", recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host",
canUpgrade: !installed, canRestart: false, serviceState: connected ? "ready" : "stopped" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByRole("button", { name: "Install and start OpenCode" }).click()
await page.getByText("The OpenCode action could not be completed. Check the current status above and retry.").waitFor()
assert.equal(await page.getByRole("button", { name: "Install and start OpenCode" }).count(), 0)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.getByRole("button", { name: "Connect and continue" }).click()
await page.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.equal(await page.getByRole("button", { name: "Connect and continue" }).count(), 0)
assert.equal(installations, 1)
assert.equal(attempts, 2)
} finally { await page.close() }
})
test("settings show management first, executable selection next, disclosures collapsed and logs last", async () => {
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
let reads = 0, starts = 0, reloads = 0
let holdCheck = false, releaseCheck: (() => void) | undefined
let releaseReload: (() => void) | undefined
const status = { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11", minimumVersion: "2.0.7",
recommendedVersion: "2.0.11", binaryPath: "C:/fixture/opencode.exe", target: "host", canUpgrade: false,
serviceState: "ready", daemonVersion: "2.0.11", canRestart: false, canReload: true }
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/storage/binaries/validate")) return route.fulfill({ json: { valid: true, version: "2.0.11" } })
if (request.url().endsWith("/api/opencode/service")) {
if (request.postDataJSON().reload) {
reloads++
await new Promise<void>(resolve => { releaseReload = resolve })
} else starts++
} else if (request.url().includes("/api/opencode/")) {
reads++
if (holdCheck) await new Promise<void>(resolve => { releaseCheck = resolve })
}
return route.fulfill({ json: status })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.equal(await page.getByRole("dialog").count(), 0)
assert.equal(await page.getByText("Minimum version", { exact: true }).isVisible(), false)
assert.equal(await page.getByRole("button", { name: "Connect and continue" }).count(), 0)
assert.equal(await page.getByRole("button", { name: "Manage OpenCode…", exact: true }).count(), 0)
const panel = page.locator(".opencode-setup-panel")
assert.equal(await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).isVisible(), false)
assert.equal(await page.locator("details").count(), 2)
assert.equal(await page.locator("details[open]").count(), 0)
const cards = page.locator("main .settings-section-stack > .settings-card")
// Version and service management first, then the executable selector without a separate Runtime header.
await cards.first().locator(".opencode-setup-panel").waitFor()
await cards.nth(1).locator(".selector-input").waitFor()
assert.equal(await cards.nth(1).locator(".settings-card-header").count(), 0)
await cards.nth(1).getByText("OpenCode Binary", { exact: true }).waitFor()
assert.equal(await page.getByRole("heading", { name: "Runtime", exact: true }).count(), 0)
assert.equal(await cards.last().getByRole("heading", { name: "OpenCode Log Level", exact: true }).count(), 1, "log levels stay below all runtime controls and disclosures")
await cards.last().locator(".selector-trigger").waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-settings-summary.png"), fullPage: true })
await page.locator(".selector-input").fill("C:/fixture/custom-opencode.exe")
await page.getByRole("button", { name: "Add", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.selectedBinary() === "C:/fixture/custom-opencode.exe")
assert.equal(await page.getByRole("dialog").count(), 0)
await page.getByText("Version details", { exact: true }).click()
await page.getByText("Minimum recommended version", { exact: true }).waitFor()
await page.getByText("Version details", { exact: true }).click()
const before = reads
holdCheck = true
await panel.getByRole("button", { name: "Check status and updates" }).click()
await panel.getByRole("button", { name: "Reading installed version..." }).waitFor()
assert.equal(await panel.getByRole("button", { name: "Reading installed version..." }).isDisabled(), true)
await page.waitForTimeout(30)
holdCheck = false; releaseCheck?.()
await panel.getByText("Status and update check complete.").waitFor()
assert.ok(reads > before)
await panel.getByText("Troubleshooting", { exact: true }).click()
await panel.getByText(/cancels pending permissions and forms/).waitFor()
await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.locator(".opencode-setup-progress").getByText("Reloading OpenCode configuration…", { exact: true }).waitFor()
await page.waitForFunction(() => document.querySelector('[aria-busy="true"]'))
// Wait for the request to reach the route before releasing its result.
for (let count = 0; count < 100 && !releaseReload; count++) await page.waitForTimeout(10)
assert.equal(reloads, 1)
releaseReload?.()
await panel.getByText("OpenCode configuration reloaded.").waitFor()
assert.equal(starts, 0, "already-connected management does not start the service")
assert.equal(await page.getByRole("dialog").count(), 0)
assert.deepEqual(errors, [])
} finally { releaseCheck?.(); releaseReload?.(); await page.close() }
})
test("French settings remain readable at narrow widths and failed checks recover visibly", async () => {
const page = await browser.newPage({ viewport: { width: 420, height: 800 } })
let fail = false
await page.route("**/api/**", route => route.fulfill(fail
? { status: 503, json: { error: "unavailable" } }
: { json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11", minimumVersion: "2.0.7",
recommendedVersion: "2.0.11", binaryPath: "C:/fixture/opencode.exe", target: "host", canUpgrade: false,
serviceState: "ready", daemonVersion: "2.0.11", canReload: true } }))
try {
await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode est connecté.", { exact: true }).waitFor()
await page.locator(".selector-input").waitFor()
assert.equal(await page.getByRole("dialog").count(), 0)
assert.equal(await page.locator("details[open]").count(), 0)
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-settings-fr-narrow.png"), fullPage: true })
assert.equal(await page.locator("main").evaluate(element => element.scrollWidth <= element.clientWidth), true)
await panel.getByText("Détails des versions", { exact: true }).click()
await panel.getByText("Version minimale recommandée", { exact: true }).waitFor()
fail = true
await panel.getByRole("button", { name: "Vérifier l’état et les mises à jour" }).click()
await panel.getByRole("alert").waitFor()
assert.equal(await panel.getByText("OpenCode est connecté.", { exact: true }).count(), 0, "a failed read cannot retain connected as current status")
await panel.getByText("Dépannage", { exact: true }).click()
assert.equal(await panel.getByRole("button", { name: "Recharger la configuration OpenCode", exact: true }).isDisabled(), true)
fail = false
await panel.getByRole("button", { name: "Vérifier l’état et les mises à jour" }).click()
await panel.getByText("Vérification de l’état et des mises à jour terminée.").waitFor()
assert.equal(await panel.getByRole("alert").count(), 0)
} finally { await page.close() }
})
const healthyStatus = { state: "ready", currentVersion: "2.0.16", latestVersion: "2.0.16", minimumVersion: "2.0.7",
recommendedVersion: "2.0.15", binaryPath: "opencode2", target: "host", canUpgrade: false, canRestart: false,
serviceState: "ready", daemonVersion: "2.0.16" }
for (const surface of ["settings", "recovery"]) test(`${surface} keeps same-version restart in troubleshooting, confirmed and separate from reload`, async () => {
const page = await browser.newPage()
const mutations: unknown[] = [], errors: string[] = []
let release: (() => void) | undefined
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", async route => {
const request = route.request()
if (request.method() === "POST" && /\/api\/opencode\/(service|update)$/.test(request.url())) {
assert.ok(request.url().endsWith("/api/opencode/service"), "maintenance must not install an update")
mutations.push(request.postDataJSON())
if (request.postDataJSON().restart) await new Promise<void>(resolve => { release = resolve })
}
return route.fulfill({ json: { ...healthyStatus, canRestart: true, canReload: true,
latestVersion: null, checkError: "update_check_failed", target: surface === "recovery" ? "wsl" : "host" } })
})
try {
await page.goto(`${url}${surface === "settings" ? "?settings=1" : ""}`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
if (surface === "recovery") await page.evaluate(() => (window as any).fixture.open())
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.equal(await panel.getByRole("button", { name: "Restart shared service", exact: true }).count(), 0, "no update-only shortcut on a healthy same-version service")
await panel.getByText("Troubleshooting", { exact: true }).click()
const restart = panel.getByRole("button", { name: "Restart OpenCode service", exact: true })
assert.equal(await restart.isEnabled(), true, "registry availability is not restart authority")
await restart.click()
const confirm = page.getByRole("dialog", { name: "Restart OpenCode service", exact: true })
await confirm.getByText(/interrupts active work in all connected clients/).waitFor()
assert.deepEqual(mutations, [])
await confirm.getByRole("button", { name: "Cancel", exact: true }).click()
assert.deepEqual(mutations, [])
await restart.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
assert.equal(await confirm.count(), 1)
await confirm.getByRole("button", { name: "Restart OpenCode service", exact: true }).click()
await page.waitForFunction(() => document.querySelector(".opencode-setup-progress"))
assert.equal(await restart.isDisabled(), true)
for (let count = 0; count < 100 && !release; count++) await page.waitForTimeout(10)
assert.ok(release)
release()
await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress"))
assert.deepEqual(mutations, [{ restart: true }])
assert.equal(await restart.isEnabled(), true, "restart remains available without another update")
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0, "troubleshooting never resumes a workspace-open continuation")
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, `opencode-troubleshooting-${surface}.png`), fullPage: true })
await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress"))
assert.deepEqual(mutations, [{ restart: true }, { reload: true }])
assert.deepEqual(errors, [])
} finally { release?.(); await page.close() }
})
test("troubleshooting remains visible with restart disabled when lifecycle authority is unavailable", async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 800 } })
let fail = false, mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill(fail ? { status: 503, json: { error: "unavailable" } }
: { json: { ...healthyStatus, canRestart: false, canReload: false } })
})
try {
await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode est connecté.", { exact: true }).waitFor()
await panel.getByText("Dépannage", { exact: true }).click()
const restart = panel.getByRole("button", { name: "Redémarrer le service OpenCode", exact: true })
assert.equal(await restart.isDisabled(), true)
assert.equal(await panel.getByRole("button", { name: "Recharger la configuration OpenCode", exact: true }).count(), 0)
assert.equal(await page.locator("main").evaluate(element => element.scrollWidth <= element.clientWidth), true)
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-troubleshooting-fr-narrow.png"), fullPage: true })
fail = true
await panel.getByRole("button", { name: "Vérifier l’état et les mises à jour", exact: true }).click()
await panel.getByRole("alert").waitFor()
assert.equal(await restart.isDisabled(), true)
assert.equal(mutations, 0)
} finally { await page.close() }
})
test("superseding a restart confirmation cancels it and unlocks maintenance without a mutation", async () => {
for (const replacement of ["alert", "confirm", "prompt"]) {
const page = await browser.newPage()
let mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill({ json: { ...healthyStatus, canRestart: true, canReload: true } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
await panel.getByText("Troubleshooting", { exact: true }).click()
const restart = panel.getByRole("button", { name: "Restart OpenCode service", exact: true })
await restart.click()
await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).waitFor()
await page.evaluate(type => (window as any).fixture.replaceDialog(type), replacement)
const next = page.getByRole("dialog", { name: "Fixture replacement", exact: true })
await next.waitFor()
await next.getByRole("button", { name: replacement === "alert" ? "OK" : "Cancel", exact: true }).click()
await next.waitFor({ state: "hidden" })
await page.waitForFunction(() => [...document.querySelectorAll(".opencode-setup-panel button")]
.filter(button => /^(Restart OpenCode service|Reload OpenCode configuration|Check status and updates)$/.test(button.textContent ?? ""))
.every(button => !(button as HTMLButtonElement).disabled))
assert.equal(await restart.isEnabled(), true)
assert.equal(await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).isEnabled(), true)
assert.equal(await panel.getByRole("button", { name: "Check status and updates", exact: true }).isEnabled(), true)
assert.equal(mutations, 0)
// Superseding a prompt must also settle its caller, without treating the
// replacement's dismissal as an affirmative answer to the old dialog.
await page.evaluate(() => (window as any).fixture.replaceDialog("prompt"))
await page.getByRole("dialog", { name: "Fixture replacement", exact: true }).waitFor()
await page.evaluate(() => (window as any).fixture.replaceDialog("alert"))
await page.waitForFunction(() => (window as any).fixture.replacementResult() === null)
await page.getByRole("dialog", { name: "Fixture replacement", exact: true }).getByRole("button", { name: "OK", exact: true }).click()
await restart.click()
await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).getByRole("button", { name: "Cancel", exact: true }).click()
assert.equal(mutations, 0)
} finally { await page.close() }
}
})
test("a changed executable or lost restart capability fences a pending confirmation", async () => {
for (const change of ["binary", "capability"]) {
const page = await browser.newPage()
let permitted = true, mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill({ json: { ...healthyStatus, canRestart: permitted } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
await panel.getByText("Troubleshooting", { exact: true }).click()
await panel.getByRole("button", { name: "Restart OpenCode service", exact: true }).click()
const confirm = page.getByRole("dialog", { name: "Restart OpenCode service", exact: true })
await confirm.waitFor()
if (change === "binary") {
await page.evaluate(() => (window as any).fixture.selectBinary("/replacement/opencode"))
await page.waitForFunction(() => (window as any).fixture.selectedBinary() === "/replacement/opencode")
} else {
permitted = false
await page.evaluate(() => (window as any).fixture.refresh())
}
await confirm.getByRole("button", { name: "Restart OpenCode service", exact: true }).click()
await confirm.waitFor({ state: "hidden" })
assert.equal(mutations, 0)
} finally { await page.close() }
}
})
test("settings troubleshooting restart retains a dismissed recovery continuation for explicit Continue", async () => {
const page = await browser.newPage()
let restarts = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) {
assert.deepEqual(route.request().postDataJSON(), { restart: true })
restarts++
}
return route.fulfill({ json: { ...healthyStatus, canRestart: true } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).click()
await panel.getByText("Troubleshooting", { exact: true }).click()
await panel.getByRole("button", { name: "Restart OpenCode service", exact: true }).click()
await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).getByRole("button", { name: "Restart OpenCode service", exact: true }).click()
await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress"))
assert.equal(restarts, 1)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
await page.evaluate(() => (window as any).fixture.reopen())
await page.getByRole("dialog").getByRole("button", { name: "Continue", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(restarts, 1)
} finally { await page.close() }
})
test("instance info explicitly confirms a global V2 reload and never calls the retired dispose endpoint", async () => {
const page = await browser.newPage()
const mutations: Array<{ url: string; body: unknown }> = []
let release!: () => void
await page.route("**/*", async route => {
const request = route.request()
if (!request.url().includes("/api/") && !request.url().includes("instance/dispose")) return route.continue()
if (request.method() === "POST" && (request.url().endsWith("/api/opencode/service") || request.url().includes("instance/dispose"))) {
mutations.push({ url: new URL(request.url()).pathname, body: request.postDataJSON() })
await new Promise<void>(resolve => { release = resolve })
}
return route.fulfill({ json: { ...healthyStatus, canReload: true } })
})
try {
await page.goto(`${url}?info=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
const reload = page.getByRole("button", { name: "Reload OpenCode configuration", exact: true })
await reload.click()
const dialog = page.getByRole("dialog")
await dialog.getByText(/every loaded location for all connected clients/).waitFor()
await dialog.getByText(/cancels pending permissions and forms/).waitFor()
assert.equal(mutations.length, 0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
assert.equal(mutations.length, 0)
await reload.click()
await dialog.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.getByRole("button", { name: "Reloading OpenCode configuration…", exact: true }).waitFor()
assert.equal(await page.getByRole("button", { name: "Reloading OpenCode configuration…", exact: true }).isDisabled(), true)
for (let count = 0; count < 100 && !release; count++) await page.waitForTimeout(10)
assert.ok(release)
release()
await page.waitForFunction(() => (window as any).fixture.notifications().some((item: any) => item.variant === "success"))
assert.deepEqual(mutations, [{ url: "/api/opencode/service", body: { reload: true } }])
assert.equal(await page.getByRole("button", { name: "Dispose instance", exact: true }).count(), 0)
assert.equal(await reload.isEnabled(), true)
} finally { release?.(); await page.close() }
})
test("instance reload failure reports an error without replaying the mutation and permits explicit retry", async () => {
const page = await browser.newPage()
let attempts = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && route.request().url().endsWith("/api/opencode/service")) {
assert.deepEqual(route.request().postDataJSON(), { reload: true })
if (++attempts === 1) return route.fulfill({ status: 502, json: { error: "service_activation_failed" } })
}
return route.fulfill({ json: { ...healthyStatus, canReload: true } })
})
try {
await page.goto(`${url}?info=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
for (const outcome of ["error", "success"]) {
await page.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.waitForFunction(outcome => (window as any).fixture.notifications()[0]?.variant === outcome, outcome)
assert.equal(attempts, outcome === "error" ? 1 : 2)
}
} finally { await page.close() }
})
test("Info reload preserves a dismissed recovery continuation without opening its pending workspace", async () => {
const page = await browser.newPage()
let reloads = 0
await page.route("**/api/**", route => {
if (route.request().method() === "POST" && route.request().url().endsWith("/api/opencode/service")) {
assert.deepEqual(route.request().postDataJSON(), { reload: true })
reloads++
}
return route.fulfill({ json: { ...healthyStatus, canReload: true } })
})
try {
await page.goto(`${url}?info=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).click()
await page.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.getByRole("dialog").getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.notifications()[0]?.variant === "success")
assert.equal(reloads, 1)
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
assert.equal(await page.getByRole("dialog").count(), 0)
await page.evaluate(() => (window as any).fixture.reopen())
await page.getByRole("dialog").getByRole("button", { name: "Continue", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(reloads, 1, "explicit recovery retains the callback without another reload")
} finally { await page.close() }
})
test("Continue rechecks a service stopped after the recovery screen opened and preserves the workspace retry", async () => {
const page = await browser.newPage()
let stopped = false, mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() !== "GET" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill({ json: { ...healthyStatus, serviceState: stopped ? "stopped" : "ready" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
const proceed = page.getByRole("button", { name: "Continue", exact: true })
await proceed.waitFor()
stopped = true
await proceed.click()
await page.getByText("OpenCode is not connected.", { exact: true }).waitFor()
assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0)
assert.equal(await page.getByRole("dialog").count(), 1)
stopped = false
await page.getByRole("button", { name: "Check status and updates" }).click()
await proceed.click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(mutations, 0)
} finally { await page.close() }
})
test("refresh events during a read coalesce into a trailing check before reporting success", async () => {
const page = await browser.newPage()
let held = false, reads = 0, release!: () => void
await page.route("**/api/**", async route => {
const stale = held
if (held) {
reads++
await new Promise<void>(resolve => { release = resolve })
} else if (reads) reads++
return route.fulfill({ json: { ...healthyStatus, serviceState: stale || !reads ? "ready" : "stopped" } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByText("OpenCode is connected.", { exact: true }).waitFor()
held = true
await page.getByRole("button", { name: "Check status and updates" }).click()
await page.waitForFunction(() => document.querySelector('[aria-busy="true"]'))
for (let count = 0; count < 100 && !release; count++) await page.waitForTimeout(10)
assert.ok(release)
await page.evaluate(() => { for (let i = 0; i < 10; i++) void (window as any).fixture.refresh() })
held = false; release()
await page.getByText("Status and update check complete.", { exact: true }).waitFor()
await page.getByText("OpenCode is not connected.", { exact: true }).waitFor()
assert.equal(reads, 2, "a burst queues one follow-up rather than losing the event or issuing ten checks")
} finally { held = false; release?.(); await page.close() }
})
test("a failed pre-action status read blocks activation and retains a visible connection retry", async () => {
const page = await browser.newPage()
let fail = false, mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() !== "GET" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill(fail ? { status: 503, json: { error: "unavailable" } }
: { json: { ...healthyStatus, serviceState: "stopped" } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByText("OpenCode is not connected.", { exact: true }).waitFor()
fail = true
await page.getByRole("button", { name: "Connect and continue", exact: true }).click()
await page.getByRole("alert").waitFor()
assert.equal(mutations, 0)
assert.equal(await page.getByRole("button", { name: "Connect and continue", exact: true }).isEnabled(), true)
fail = false
await page.getByRole("button", { name: "Check status and updates" }).click()
await page.getByText("Status and update check complete.", { exact: true }).waitFor()
assert.equal(await page.getByRole("alert").count(), 0)
} finally { await page.close() }
})
test("a service discovery error is not announced as a successful check", async () => {
const page = await browser.newPage()
await page.route("**/api/**", route => route.fulfill({ json: { ...healthyStatus,
serviceState: "error", serviceError: "service_check_failed" } }))
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByRole("alert").waitFor()
await page.getByRole("button", { name: "Check status and updates" }).click()
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(await page.getByText("Status and update check complete.", { exact: true }).count(), 0)
assert.equal(await page.getByText("OpenCode is connected.", { exact: true }).count(), 0)
} finally { await page.close() }
})
test("changing executable fences an old read and its queued follow-up", async () => {
const page = await browser.newPage()
let hold = false, changed = false, release!: () => void, reads = 0
await page.route("**/api/**", async route => {
const status = { ...healthyStatus, binaryPath: changed ? "new-opencode" : "old-opencode", serviceState: changed ? "stopped" : "ready" }
if (hold) { reads++; await new Promise<void>(resolve => { release = resolve }) }
else if (reads) reads++
return route.fulfill({ json: status })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByText("OpenCode is connected.", { exact: true }).waitFor()
hold = true
await page.getByRole("button", { name: "Check status and updates" }).click()
for (let count = 0; count < 100 && !release; count++) await page.waitForTimeout(10)
assert.ok(release)
await page.evaluate(() => { void (window as any).fixture.refresh() })
hold = false; changed = true
await page.evaluate(() => (window as any).fixture.invalidate())
await page.getByText("OpenCode is not connected.", { exact: true }).waitFor()
const oldRead = page.waitForResponse(response => response.url().includes("/api/opencode/update"))
release()
await oldRead
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 30)))
assert.equal(await page.getByText("old-opencode", { exact: true }).count(), 0)
assert.equal(await page.getByText("OpenCode is connected.", { exact: true }).count(), 0)
assert.equal(reads, 2)
} finally { hold = false; release?.(); await page.close() }
})
test("continuing an already-connected recovery retries only the pending workspace", async () => {
const page = await browser.newPage()
let mutations = 0
await page.route("**/api/**", route => {
if (route.request().method() !== "GET" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.11", latestVersion: "2.0.11", minimumVersion: "2.0.7",
recommendedVersion: "2.0.11", binaryPath: "opencode2", target: "host", canUpgrade: false, serviceState: "ready", daemonVersion: "2.0.11" } })
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.evaluate(() => (window as any).fixture.open())
await page.getByRole("button", { name: "Continue", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.resumed() === 1)
assert.equal(mutations, 0)
assert.equal(await page.getByRole("dialog").count(), 0)
} finally { await page.close() }
})
test("settings expose and install an optional update directly, with retry feedback and no implicit restart", async () => {
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } })
let installed = false, attempts = 0, starts = 0
let releaseInstall: (() => void) | undefined
await page.route("**/api/**", async route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
attempts++
if (attempts === 1) return route.fulfill({ status: 502, json: { error: "upgrade_failed" } })
await new Promise<void>(resolve => { releaseInstall = resolve })
installed = true
return route.fulfill({ json: { success: true, version: "2.0.11" } })
}
if (request.url().endsWith("/api/opencode/service")) {
assert.equal(request.postDataJSON().restart, false)
starts++
}
return route.fulfill({ json: { state: "ready", currentVersion: installed ? "2.0.11" : "2.0.9",
latestVersion: "2.0.11", updateAvailable: !installed, canUpgrade: !installed,
minimumVersion: "2.0.7", recommendedVersion: "2.0.11", versionAssessment: "tested",
binaryPath: "opencode2", target: "host", daemonVersion: "2.0.9",
serviceState: installed ? "restart_available" : "ready", canRestart: installed } })
})
try {
await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.getByText("OpenCode 2.0.11 est disponible.", { exact: true }).waitFor()
const update = page.getByRole("button", { name: "Mettre à jour vers OpenCode 2.0.11", exact: true })
await update.waitFor()
assert.equal(await page.getByRole("dialog").count(), 0)
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-settings-update.png") })
await update.click()
await page.getByRole("alert").waitFor()
assert.equal(await page.getByRole("dialog").count(), 0)
await update.click()
await page.locator(".opencode-setup-panel button:disabled:visible").filter({ hasText: "OpenCode" }).waitFor()
for (let count = 0; count < 100 && !releaseInstall; count++) await page.waitForTimeout(10)
assert.ok(releaseInstall)
releaseInstall()
await page.getByText("OpenCode est à jour.", { exact: true }).waitFor()
await page.waitForFunction(() => !document.querySelector('[role="alert"]'))
assert.equal(attempts, 2)
assert.equal(starts, 1)
assert.equal(await update.count(), 0)
assert.equal(await page.getByRole("dialog").count(), 0)
await page.getByText(/Vous pouvez continuer avec le service/).waitFor()
} finally { releaseInstall?.(); await page.close() }
})
test("settings distinguish manual updates and failed registry checks from an up-to-date installation", async () => {
const page = await browser.newPage()
let offline = false
await page.route("**/api/**", route => route.fulfill({ json: { state: "ready", currentVersion: "2.0.9",
latestVersion: offline ? null : "2.0.11", updateAvailable: offline ? null : true, canUpgrade: false,
checkError: offline ? "update_check_failed" : undefined, minimumVersion: "2.0.7", recommendedVersion: "2.0.11",
binaryPath: "custom-opencode", target: "host", serviceState: "ready", daemonVersion: "2.0.9" } }))
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
await page.getByText("OpenCode 2.0.11 is available.", { exact: true }).waitFor()
await page.getByText(/Select a supported executable/).waitFor()
assert.equal(await page.getByRole("button", { name: "Update to OpenCode 2.0.11" }).count(), 0)
offline = true
await page.reload({ waitUntil: "domcontentloaded" })
await page.getByText("Could not read the installed OpenCode version.", { exact: true }).waitFor()
assert.equal(await page.getByText("OpenCode is up to date.", { exact: true }).count(), 0)
assert.equal(await page.getByText("OpenCode 2.0.11 is available.", { exact: true }).count(), 0)
} finally { await page.close() }
})
test("OpenCode 1.x npm replacement is confirmed explicitly and a failure after removal stays retryable", async () => {
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
const posts: unknown[] = []
let phase: "legacy" | "removed" | "installed" = "legacy"
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON())
if (phase === "legacy") { phase = "removed"; return route.fulfill({ status: 502, json: { success: false, error: "legacy_removed_install_failed" } }) }
phase = "installed"
return route.fulfill({ json: { success: true, version: "2.0.30" } })
}
return route.fulfill({ json: {
state: phase === "installed" ? "ready" : phase === "legacy" ? "update_required" : "missing",
currentVersion: phase === "installed" ? "2.0.30" : phase === "legacy" ? "1.18.10" : null, latestVersion: "2.0.30",
updateAvailable: phase !== "installed", canUpgrade: phase !== "installed", minimumVersion: "2.0.27", recommendedVersion: "2.0.29",
binaryPath: phase === "removed" ? "opencode2" : "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", installationSource: phase === "removed" ? undefined : "path",
...(phase === "legacy" ? { legacyReplacement: { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" } } : {}),
versionAssessment: phase === "installed" ? "untested" : "incompatible", target: "host", canRestart: false,
serviceState: phase === "installed" ? "ready" : undefined, daemonVersion: phase === "installed" ? "2.0.30" : undefined,
} })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
const replace = panel.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true })
await replace.waitFor()
await panel.getByText(/OpenCode 1\.18\.10 was installed with npm/).waitFor()
assert.equal(await panel.getByText(/Select a supported executable/).count(), 0, "a replaceable npm installation is never a manual dead end")
await replace.click()
const dialog = page.getByRole("dialog", { name: "Replace OpenCode 1.18.10?", exact: true })
await dialog.getByText(/imports OpenCode 1\.x conversations itself/).waitFor()
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-legacy-confirm.png") })
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await dialog.waitFor({ state: "hidden" })
assert.deepEqual(posts, [], "dismissing the confirmation never installs")
await replace.click()
await dialog.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("alert").filter({ hasText: /OpenCode 1\.x was removed, but OpenCode 2 could not be installed/ }).waitFor()
const consent = { replaceLegacy: { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" } }
assert.deepEqual(posts, [consent], "the confirmation names the exact installation shown")
const retry = panel.getByRole("button", { name: "Install and start OpenCode", exact: true })
await retry.click()
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.deepEqual(posts, [consent, {}], "retry is an ordinary installation, never an automatic replay")
assert.equal(await page.getByRole("alert").count(), 0)
} finally { await page.close() }
})
test("an older winget OpenCode stays untouched while OpenCode 2 is installed beside it", async () => {
const page = await browser.newPage()
const posts: unknown[] = []
const statusReads: string[] = []
let installed = false
const foreignInstallation = { path: "C:/Users/fixture/AppData/Local/Microsoft/WinGet/Links/opencode.exe", version: "1.19.0", source: "winget", packageId: "SST.opencode" }
await page.route("**/api/**", route => {
const request = route.request()
if (request.method() === "GET" && new URL(request.url()).pathname === "/api/opencode/update") statusReads.push(new URL(request.url()).search)
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON()); installed = true
return route.fulfill({ json: { success: true, version: "2.0.30" } })
}
return route.fulfill({ json: { state: installed ? "ready" : "update_required", currentVersion: installed ? "2.0.30" : "1.19.0",
latestVersion: "2.0.30", updateAvailable: !installed, canUpgrade: !installed, minimumVersion: "2.0.27", recommendedVersion: "2.0.29",
binaryPath: installed ? "C:/Users/fixture/AppData/Roaming/npm/node_modules/@opencode/cli/bin/opencode.exe" : foreignInstallation.path,
installationSource: installed ? "managed" : "path", foreignInstallation, versionAssessment: installed ? "untested" : "incompatible",
target: "host", canRestart: false, serviceState: installed ? "ready" : undefined, daemonVersion: installed ? "2.0.30" : undefined } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
const install = panel.getByRole("button", { name: "Install OpenCode 2 for CodeNomad", exact: true })
await install.waitFor()
const notice = panel.getByRole("note")
await notice.getByText(/OpenCode 1\.19\.0 was found at .*WinGet.*requires OpenCode 2\.0\.27 or later and does not modify this installation/).waitFor()
await notice.getByText("This older version was installed with winget.", { exact: true }).waitFor()
await notice.getByText(/alongside the older version/).waitFor()
await notice.getByText(/may still start the older version/).waitFor()
assert.equal(await notice.locator("code").innerText(), "winget uninstall SST.opencode")
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-winget-alongside.png"), fullPage: true })
assert.ok(statusReads.length > 0 && statusReads.every(search => search === ""), "automatic reads use cached server probes")
await panel.getByRole("button", { name: "Check status and updates", exact: true }).click()
await panel.getByText("Status and update check complete.", { exact: true }).waitFor()
assert.equal(statusReads.at(-1), "?recheck=1", "the explicit Check re-probes installations")
const automaticReads = statusReads.length
await install.click()
await panel.getByText("OpenCode is connected.", { exact: true }).waitFor()
assert.equal(await page.getByRole("dialog").count(), 0, "installing beside a foreign OpenCode needs no removal confirmation")
assert.deepEqual(posts, [{}])
assert.ok(statusReads.slice(automaticReads).every(search => search === ""), "post-install reads never force a probe")
await panel.getByText(/uses its OpenCode 2 installation in your user npm folder/).waitFor()
assert.equal(await notice.getByText(/alongside the older version/).count(), 0)
await notice.getByText(/may still start the older version/).waitFor()
assert.equal(await install.count(), 0)
} finally { await page.close() }
})
test("a broken OpenCode 1.x npm install stays replaceable and a partial removal is reported truthfully", async () => {
const page = await browser.newPage()
const posts: unknown[] = []
const legacyReplacement = { path: "C:/Users/fixture/AppData/Roaming/npm/opencode.cmd", version: "1.18.10" }
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
posts.push(request.postDataJSON())
return route.fulfill({ status: 502, json: { success: false, error: "legacy_partially_removed" } })
}
// The 1.x placeholder executable fails `--version`, so the probe reports an error state.
return route.fulfill({ json: { state: "error", currentVersion: null, latestVersion: "2.0.30", updateAvailable: true, canUpgrade: true,
minimumVersion: "2.0.27", recommendedVersion: "2.0.29", binaryPath: legacyReplacement.path, installationSource: "path", legacyReplacement,
versionAssessment: "untested", target: "host", canRestart: false } })
})
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
await panel.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("dialog", { name: "Replace OpenCode 1.18.10?", exact: true })
.getByRole("button", { name: "Replace OpenCode 1.18.10 with OpenCode 2", exact: true }).click()
await page.getByRole("alert").filter({ hasText: /may be only partly removed.*npm uninstall -g opencode-ai/ }).waitFor()
assert.deepEqual(posts, [{ replaceLegacy: legacyReplacement }])
} finally { await page.close() }
})
test("an outdated foreign OpenCode 2 is described by the minimum version, not as OpenCode 1.x", async () => {
const page = await browser.newPage()
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "2.0.20", latestVersion: "2.0.30",
updateAvailable: true, canUpgrade: true, minimumVersion: "2.0.27", recommendedVersion: "2.0.29", binaryPath: "/opt/homebrew/bin/opencode",
installationSource: "path", foreignInstallation: { path: "/opt/homebrew/bin/opencode", version: "2.0.20", source: "other" },
versionAssessment: "incompatible", target: "host", canRestart: false } }))
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const notice = page.locator(".opencode-setup-panel").getByRole("note")
await notice.getByText("OpenCode 2.0.20 was found at /opt/homebrew/bin/opencode. CodeNomad requires OpenCode 2.0.27 or later and does not modify this installation.", { exact: true }).waitFor()
assert.equal(await notice.getByText(/winget/).count(), 0)
} finally { await page.close() }
})
test("an older OpenCode occupying the user npm folder explains why OpenCode 2 cannot be installed beside it", async () => {
const page = await browser.newPage()
const occupying = "/home/fixture/.local/bin/opencode"
await page.route("**/api/**", route => route.fulfill({ json: { state: "update_required", currentVersion: "1.17.18", latestVersion: "2.1.2",
updateAvailable: true, canUpgrade: false, minimumVersion: "2.1.2", recommendedVersion: "2.1.2", binaryPath: occupying, installationSource: "path",
foreignInstallation: { path: occupying, version: "1.17.18", source: "other", blocked: { reason: "occupied", path: occupying } },
versionAssessment: "incompatible", target: "host", canRestart: false } }))
try {
await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const panel = page.locator(".opencode-setup-panel")
const notice = panel.getByRole("note")
await notice.getByText(`OpenCode 2 cannot be installed for CodeNomad while ${occupying} uses the opencode command name in your user npm folder. Move or rename that command, then choose Check status and updates.`, { exact: true }).waitFor()
assert.equal(await panel.getByRole("button", { name: "Install OpenCode 2 for CodeNomad", exact: true }).count(), 0)
assert.equal(await notice.getByText(/alongside the older version/).count(), 0)
await panel.getByRole("button", { name: "Check status and updates", exact: true }).waitFor()
} finally { await page.close() }
})
test("user npm PATH repair is available at the current version and installation conflicts retain explicit retry", async () => {
const page = await browser.newPage({ viewport: { width: 420, height: 900 } })
let migrated = false, attempts = 0, connects = 0
await page.route("**/api/**", route => {
const request = route.request()
if (request.url().endsWith("/api/storage/binaries/validate")) return route.fulfill({ json: { valid: true, version: "2.0.14" } })
if (request.url().endsWith("/api/opencode/update") && request.method() === "POST") {
if (++attempts === 1) return route.fulfill({ status: 409, json: { error: "installation_in_use" } })
migrated = true
return route.fulfill({ json: { success: true, version: "2.0.15" } })
}
if (request.url().endsWith("/api/opencode/service")) {
assert.equal(request.postDataJSON().restart, false)
connects++
}
return route.fulfill({ json: { state: "ready", currentVersion: "2.0.15", latestVersion: "2.0.15",
updateAvailable: false, canUpgrade: !migrated, needsSharedInstallation: !migrated,
installationSource: migrated ? "path" : "user", binaryPath: migrated ? "C:/Users/fixture/AppData/Roaming/npm/opencode2.cmd" : "C:/Users/fixture/AppData/Roaming/npm/node_modules/@opencode/cli/bin/opencode.exe",
minimumVersion: "2.0.7", recommendedVersion: "2.0.11", versionAssessment: "untested", target: "host",
serviceState: "ready", daemonVersion: "2.0.15", canRestart: false } })
})
try {
await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 })
const install = page.getByRole("button", { name: "Installer pour l’utilisateur et configurer le PATH", exact: true })
await install.waitFor()
await page.getByText("Installation npm utilisateur trouvée hors du PATH actuel.", { exact: true }).waitFor()
assert.match(await page.locator(".selector-badge-version").innerText(), /2\.0\.15/, "selector uses the current effective version rather than its old validation cache")
assert.equal(await page.locator("main").evaluate(element => element.scrollWidth <= element.clientWidth), true)
if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-shared-path-repair-fr.png"), fullPage: true })
await install.click()
await page.getByRole("alert").filter({ hasText: /L’exécutable OpenCode est utilisé/ }).waitFor()
assert.equal(connects, 0)
await install.click()
await page.getByText("Exécutable trouvé dans le PATH du serveur.", { exact: true }).waitFor()
await page.waitForFunction(() => !document.querySelector('[aria-busy="true"]'))
assert.equal(connects, 1)
assert.equal(await install.count(), 0)
assert.equal(await page.getByRole("alert").count(), 0)
} finally { await page.close() }
})