diff --git a/.gitignore b/.gitignore index 8f9c5f9fd59..3aa3ff0c143 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ desktop/src-tauri/resources/ desktop/src-tauri/widget/ desktop/src-tauri/gen/ desktop/src-tauri/target/ +# Running `bun install` inside desktop/ writes a second lockfile that shadows the root one for +# any command run from that directory. CI installs the desktop workspace with --frozen-lockfile +# on an older Bun, so a shadowing lockfile written by a newer Bun fails the job with "Unknown +# lockfile version" before anything is built. The root lockfile is the only one this repo keeps. +desktop/bun.lock diff --git a/desktop/README.md b/desktop/README.md index 9106a50f67f..02e793a4766 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -26,6 +26,21 @@ bun run prepare-widget bunx tauri build ``` +## Building locally without signing keys + +`bunx tauri build` always produces the updater archive and then refuses to finish without +`TAURI_SIGNING_PRIVATE_KEY`, so a local build ends on `A public key has been found, but no private +key` **after** writing `OpenCodex.app` and the dmg. That exit code is right for a release and +misleading on a workstation. + +```sh +bun run build:local +``` + +This asks for the app and dmg only, so no updater archive is produced and none is expected to be +signed. It prints the bundle path and exits zero. The release path below is unchanged: a published +updater artifact still has to be signed. + ## Release packaging and updates The release workflow builds a macOS DMG, Windows MSI, Linux AppImage, and Debian package. diff --git a/desktop/package.json b/desktop/package.json index 09668ce09b7..7f168ff8e8b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "tauri dev", "build": "tauri build", + "build:local": "bun scripts/build-local.ts", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", "prepare-widget": "bash scripts/build-widget.sh" }, diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts new file mode 100644 index 00000000000..633a27b6c15 --- /dev/null +++ b/desktop/scripts/build-local.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun +/** + * Unsigned local bundle build. + * + * `tauri build` always produces the updater archive, because `bundle.createUpdaterArtifacts` is + * true and `plugins.updater.pubkey` is set. Without `TAURI_SIGNING_PRIVATE_KEY` it then refuses to + * finish: + * + * Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg + * A public key has been found, but no private key. + * Error failed to build app + * + * Both bundles exist at that point. The non-zero exit is correct for a release — an unsigned + * updater artifact reaching users is worse than a failed build — but for someone building on their + * own machine it reports a failure for a signing step they were never meant to perform, and a + * wrapper script cannot tell it apart from a real failure. + * + * So this does not relax the check. It turns the updater artifact off for this one invocation, so + * there is nothing to sign and nothing is skipped unsigned. Selecting bundle targets is not enough: + * `createUpdaterArtifacts` is a config flag, so `--bundles app,dmg` still produces + * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. + */ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** Bundle targets that carry no updater archive. */ +const LOCAL_BUNDLES = ["app", "dmg"] as const; + +/** + * Config merged over `tauri.conf.json` for this invocation only. + * + * Turning the artifact off is what makes the signing key unnecessary, rather than leaving it + * required and unmet. The committed config keeps `createUpdaterArtifacts: true`, so the release + * build is untouched. + */ +const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); + +function run(): number { + const extra = process.argv.slice(2); + const args = [ + "tauri", "build", "--ci", + "--bundles", LOCAL_BUNDLES.join(","), + "--config", LOCAL_CONFIG, + ...extra, + ]; + const result = spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }); + if (result.error) { + console.error(`[build:local] could not start tauri: ${result.error.message}`); + return 1; + } + return result.status ?? 1; +} + +const status = run(); +if (status === 0) { + const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); + const app = join(bundleRoot, "macos", "OpenCodex.app"); + // Naming what exists is the point of the script: the previous output ended on an error line, so + // the artifacts it had already written were the least visible thing in it. + if (existsSync(app)) console.log(`[build:local] ${app}`); + console.log("[build:local] updater artifacts skipped; release signing is unchanged."); +} +process.exit(status); diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8683695613f..2548b000cd0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,3 +33,13 @@ codegen-units = 1 lto = "thin" opt-level = "s" strip = "symbols" + +# Build scripts and proc macros are compiled for the host and loaded by rustc, so +# they must keep the symbols it resolves them through. `strip = "symbols"` above +# applies to them as well without this override, and a stripped proc-macro dylib +# fails to load with a bare `can't find crate`, naming the macro rather than the +# profile that removed it. `ctor-proc-macro`, pulled in by `tauri-utils`, is the +# one this repository hits: the release build stops at `pub use ctor_proc_macro::ctor` +# while the dev profile compiles the same graph. +[profile.release.build-override] +strip = false diff --git a/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md new file mode 100644 index 00000000000..db1792aa6f1 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md @@ -0,0 +1,52 @@ +# Desktop app stabilization — local build and conflict handling + +Status: OPEN. Opened against `dev` after the desktop stack landed as #5318 and the Claude Desktop +chain as #5319. This unit records what the first real local build found and what the app does when +it meets something already running. + +## The release profile could not build the app at all + +`cargo build --release` stopped at `ctor`, a transitive dependency of `tauri-utils`: + +``` +error[E0463]: can't find crate for `ctor_proc_macro` + --> ctor-0.8.0/src/lib.rs:244:9 +``` + +The same graph compiles in the dev profile. The difference is `[profile.release] strip = "symbols"`, +which cargo applies to build scripts and proc macros as well as to the crate being built. A proc +macro is a host dylib that rustc loads by symbol, so stripping it leaves a file rustc cannot read, +and the error names the macro rather than the profile that removed its symbols. + +`[profile.release.build-override] strip = false` restores it. The fix is one line plus the reason, +because the next person to read `can't find crate` will otherwise go looking at the dependency. + +This did not surface earlier because nothing had built the desktop app in release outside CI, and +CI's toolchain tolerated the stripped dylib. It is reproducible here on rustc 1.95.0. + +## Two installations at once + +The widget snapshot has two writers that target the same path: + +- `app/Sources/MenuBarCore/WidgetSnapshot.swift` builds it from + `~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json` +- `desktop/src-tauri/src/widget.rs:246` writes the same file from Rust + +Each deduplicates with its own in-process `lastWritten`, so two live writers do not settle: each +sees the other's file as changed, rewrites it, and calls `reloadTimelines`. The current build no +longer ships a standalone menu bar executable — `app/Package.swift` declares only the widget appex +and its test harness — so this is reachable only for a user who still has an earlier standalone +build installed. `tauri_plugin_single_instance` guards a second copy of the same bundle and cannot +see a different one. + +## Proxy ownership + +`spawned_by_us` in `desktop/src-tauri/src/lib.rs` records whether the app started the proxy, and +`tray.rs` enables **Stop proxy** from it. It is consumed with `swap(false)`, so the behaviour after +a stop-and-restart cycle needs checking rather than assuming. + +## Execution note + +This unit builds and installs locally at the maintainer's explicit request, which is a deliberate +exception to the no-local-build rule the surrounding batch worked under. Test suites are still not +run here. diff --git a/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md new file mode 100644 index 00000000000..af51c49d63e --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md @@ -0,0 +1,69 @@ +# Roadmap — six items, four work phases + +Status: LOCKED at wp1. Each later phase consumes one decade doc below and revalidates it at its +own P. The evidence in [000_local_build.md](000_local_build.md) is the ground truth; this file +turns it into an order of work. + +## What the local build and run actually showed + +Nothing about the usage feature was missing. `gui/dist` was five days old, so the bundle the +service served predated #5196 and could not contain the companion panel. `AGENTS.md` already says +the dashboard is served from `gui/dist` and lists `bun run build:gui`, so rebuilding after a +fast-forward was the existing procedure and skipping it was the mistake. After the rebuild the page +renders 74,974 requests, 18.66B tokens, 99% coverage, and a **menu bar and widget** section reading +"desktop app connected · just now". + +That reframes the work: five of the six items are real defects, and the sixth is the guard that +stops this particular mistake from being silent. + +## Order and why + +| Phase | Items | Why here | +| --- | --- | --- | +| wp2 | release profile, stale dist, updater-key exit | Nothing else can be built or verified until the release profile compiles; the dist guard belongs with it because both are "the build lied about its state". | +| wp3 | Claude Desktop first-party reachability | Independent of the build, and already verified by hand, so it lands on its own evidence. | +| wp4 | SVG app icons, widget gallery verdict | Both need a signed-or-explained bundle, so they come after the build is trustworthy. | + +## 020 — release profile, stale dist, updater key + +`[profile.release] strip = "symbols"` is applied by cargo to build scripts and proc macros as well +as to the crate being built. A proc macro is a host dylib rustc loads by symbol, so stripping it +produces `can't find crate for ctor_proc_macro` — an error that names the macro and never mentions +the profile. `[profile.release.build-override] strip = false` is the fix, already committed with +its reason. + +The stale-dist guard reports rather than repairs. The dashboard is a served artifact, so the honest +signal is "the bundle you are looking at is older than the source that produced it", surfaced where +someone will read it. Rebuilding automatically at startup would make a serving process do a build, +which is the wrong trade for a proxy. + +The updater-key exit is smaller: a local build that produced both bundles should not end on a +failure line about a signing key it was never given. + +## 030 — Claude Desktop first-party reachability + +`resolveClaudeDesktopMode` returns `gateway` when a gateway apply marker exists, and an explicit +`desktopMode` wins over everything. Both rules are right on their own: neither should flip a +working install silently. Together they mean the help text calls first-party "(default)" while an +existing user can never arrive there without discovering `--first-party` unaided. + +The fix is not to change the resolution. It is to make the choice visible at the moment an apply +happens, so a gateway apply says what it chose, that first-party exists, and how to switch. + +## 040 — SVG app icons and the widget verdict + +Icons today are a raster set with no vector source, so every size is an independent artifact that +can drift. One SVG source with a generation step makes the sizes derived rather than restated — +the same principle the test-layout registries follow. + +The widget question is answered with evidence, not hope. The bundle carries +`PlugIns/OpenCodexWidget.appex` and the app is ad-hoc signed +(`Identifier=opencodex_desktop-b89067d97e1c189c`, `flags=0x20002(adhoc,linker-signed)`), so the +phase records whether the widget appears in the gallery under that signing and, if it does not, +what specifically rejects it. + +## Constraints carried through every phase + +Stacked PRs, all pushes `--no-verify`, CI tracked after the fact rather than waited on. No local +test suite. Builds and real launches are the verification, because this unit exists precisely +because a build that was never run locally was assumed to work. diff --git a/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md new file mode 100644 index 00000000000..e25669f18ff --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md @@ -0,0 +1,49 @@ +# Acceptance evidence per phase + +The roadmap says what each phase does. This says what closes it, in terms of evidence that a +passing command or a rendered page does not by itself provide. + +## wp2 — release profile, stale dist, updater key + +**Release profile.** `cargo build --release` completes for the desktop crate. The regression is not +a test that runs cargo; it is an assertion that the release profile carries a build-override which +does not strip, because the failure mode is a profile setting and the symptom appears in an +unrelated crate. A test that only built something would pass on a machine whose rustc tolerates a +stripped proc-macro dylib, which is exactly how this reached `dev`. + +**Stale dist.** The check compares the newest source timestamp under `gui/src` against the built +bundle and reports when the bundle is older. It closes when a deliberately stale bundle produces +the report and a fresh one does not. Reporting is the contract: the proxy must not start a build. + +**Updater key.** A local bundle build that produced its artifacts ends by naming them, and the +missing updater key is stated as a skipped signing step rather than a failure. It closes when the +command's exit status reflects whether the bundles exist. + +## wp3 — Claude Desktop first-party reachability + +Closes when an apply that resolves to gateway says so, names first-party as the alternative, and +gives the exact command that switches. The resolution rules stay as they are: neither an explicit +`desktopMode` nor an existing apply marker may be overridden silently, because a working install +must not flip underneath its user. + +The evidence is the apply output on a machine that already carries a gateway marker — this one. +A unit test asserting the string is not sufficient on its own, because the defect was that the +help text and the resolved behaviour disagreed, and only running the real path shows which wins. + +## wp4 — SVG icons and the widget verdict + +**Icons.** One SVG source exists and every raster size is generated from it by a committed script. +It closes when regenerating produces byte-identical output for unchanged input, so the sizes are +derived rather than restated. + +**Widget.** The verdict is recorded either way. If the widget appears in the gallery under ad-hoc +signing, that is the finding. If it does not, the phase records what rejects it — the specific +system log line or the signing requirement — rather than reporting an absence. An unverified +"should work" closes nothing. + +## What none of these accept + +A green pull request is not evidence for any item here, because every one of them was invisible to +CI. The release profile failed only on a toolchain CI does not use, the stale bundle is a runtime +artifact CI rebuilds, the mode disagreement needs an existing install, and the widget needs a real +login session. Each phase therefore carries a local run alongside its hosted check. diff --git a/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md new file mode 100644 index 00000000000..fe67b3411a8 --- /dev/null +++ b/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md @@ -0,0 +1,59 @@ +# wp2 — the build telling the truth about its own state + +Two defects and one rough edge, all of the same shape: the build produced a state nobody could see +from its output. + +## 1. The release profile could not compile the app (landed) + +`cargo build --release` stopped at `ctor` with `can't find crate for ctor_proc_macro`. The dev +profile compiled the identical graph. `[profile.release] strip = "symbols"` is applied by cargo to +build scripts and proc macros as well as to the crate under build, and a proc macro is a host dylib +rustc loads by symbol, so stripping it leaves a file rustc cannot read. The error names the macro +and never mentions the profile that removed its symbols. + +`[profile.release.build-override] strip = false` in `desktop/src-tauri/Cargo.toml`, with the reason +in a comment because the next reader of that error will otherwise go looking at the dependency. +Verified by `cargo build --release -p ctor` failing before and passing after, and by the full +`tauri build` reaching both bundles afterwards. + +## 2. A stale dashboard bundle was invisible (landed) + +`gui/dist` was five days old, so the served page predated #5196 and could not contain the usage +companion panel. Nothing failed — the proxy answered and the page loaded, and the feature simply was +not in the bundle, which reads as the feature being broken. + +`src/server/gui-freshness.ts` compares the newest mtime under `gui/src` with the served bundle and +`ocx status` prints the rebuild command beside the dashboard URL. It reports and never rebuilds: a +proxy compiling a frontend at startup trades silent staleness for a slow, surprising start. + +Unknown is not stale, because a packaged install ships no `gui/src` and a missing bundle is a +separate condition. `node_modules` is skipped so a dependency install cannot make sources look +newer than they are. Four regressions in `tests/server/server-gui-bundle-freshness.test.ts` hold those +cases, and the live check was confirmed by touching a source file and watching the warning appear +and then disappear after a rebuild. + +## 3. A local build ends on a failure after succeeding (this phase) + +`createUpdaterArtifacts` is true and `plugins.updater.pubkey` is set in `tauri.conf.json`, so Tauri +always builds the updater archive and then refuses to finish without `TAURI_SIGNING_PRIVATE_KEY`: + +``` +Finished 2 bundles at: .../OpenCodex.app, .../OpenCodex_2.61.0_aarch64.dmg +A public key has been found, but no private key. Make sure to set TAURI_SIGNING_PRIVATE_KEY +Error failed to build app +``` + +Both bundles exist at that point. The command still exits non-zero, so a developer building locally +sees a failure for a signing step they were never meant to perform, and a script wrapping the build +cannot distinguish this from a real failure. + +The release path must keep failing here: an unsigned updater artifact shipped to users is worse +than a failed release. So the fix is not to relax the check but to give the local build a path that +does not ask for the artifact at all — an explicit script that builds the app and dmg without +updater artifacts, documented beside the existing release instructions. + +### Acceptance + +A local build command produces `OpenCodex.app` and the dmg and exits zero without a signing key. +The release instructions still describe the signed path, and nothing weakens the requirement that a +published updater artifact is signed. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dcc7dafffde..574ec26af9c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -578,6 +578,7 @@ "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", + "server-gui-bundle-freshness.test.ts": "server", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/src/cli/index.ts b/src/cli/index.ts index 98843494e44..ff48ca71f02 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,9 @@ #!/usr/bin/env bun import { spawn } from "node:child_process"; import { homedir } from "node:os"; +import { join } from "node:path"; +import { findGuiDist } from "../server/gui-static"; +import { inspectGuiBundleFreshness, staleGuiBundleLines } from "../server/gui-freshness"; // Best-effort recovery for runtime execution and spawned children if launched // from an unlinked/deleted working directory (runs after hoisted ESM module imports). @@ -1596,6 +1599,16 @@ async function handleStatus() { } } console.log(` Dashboard: ${status.json.dashboard.url}${local}`); + // The dashboard is a build artifact, so a checkout that moved without `bun run build:gui` keeps + // serving the previous bundle and every feature added since simply does not appear (#5196's + // usage panel was invisible this way for five days). Reported next to the dashboard URL, which + // is where someone looks when the page is wrong. + for (const line of staleGuiBundleLines(inspectGuiBundleFreshness({ + bundlePath: findGuiDist(), + sourcePath: join(import.meta.dir, "..", "..", "gui", "src"), + }))) { + console.log(` ${line}`); + } console.log(` Config: ${status.json.paths.config}${local}`); console.log(` PID file: ${status.json.paths.pid}${local}`); console.log(` Runtime: ${status.json.paths.runtime}${local}`); diff --git a/src/server/gui-freshness.ts b/src/server/gui-freshness.ts new file mode 100644 index 00000000000..1d3c6b317d8 --- /dev/null +++ b/src/server/gui-freshness.ts @@ -0,0 +1,103 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Whether the served dashboard bundle predates the sources it was built from. + * + * The dashboard is a build artifact: `AGENTS.md` records that `gui/` is served from `gui/dist`, + * so a checkout that moves forward without `bun run build:gui` keeps serving the previous bundle. + * Nothing fails when that happens. The proxy answers, the page loads, and every feature added since + * the last build is simply absent — which reads as the feature being broken rather than unbuilt. + * A five-day-old bundle hid the entire menu-bar and widget section of the Usage page this way. + * + * This reports; it never rebuilds. A proxy that compiled a frontend while starting would trade a + * silent staleness for a slow, surprising start, and the rebuild belongs to whoever moved the + * checkout. + */ +export interface GuiBundleFreshness { + /** Absolute path of the served bundle, or null when no bundle was found. */ + bundlePath: string | null; + /** Newest mtime under the bundle, in epoch milliseconds. */ + bundleModifiedMs: number | null; + /** Newest mtime under the GUI sources, in epoch milliseconds. */ + sourceModifiedMs: number | null; + /** True only when both sides are known and the sources are strictly newer. */ + stale: boolean; +} + +/** Directory entries that never carry meaningful build input or output timestamps. */ +const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".vite", ".cache"]); + +/** + * Newest mtime beneath `root`, or null when the tree is missing or empty. + * + * Walking is bounded by `maxEntries` because this runs on a status path: a pathological tree must + * cost a predictable amount rather than stalling the command that reports on it. Hitting the bound + * yields the newest value seen so far, which can only make the comparison more conservative. + */ +export function newestModifiedMs(root: string, maxEntries = 20_000): number | null { + if (!existsSync(root)) return null; + let newest: number | null = null; + let seen = 0; + const queue: string[] = [root]; + while (queue.length > 0) { + const current = queue.pop()!; + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (seen >= maxEntries) return newest; + if (entry.isSymbolicLink()) continue; + const full = join(current, entry.name); + if (entry.isDirectory()) { + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + queue.push(full); + continue; + } + if (!entry.isFile()) continue; + seen += 1; + try { + const mtime = statSync(full).mtimeMs; + if (newest === null || mtime > newest) newest = mtime; + } catch { + // A file that vanished mid-walk cannot make the bundle look fresher than it is. + } + } + } + return newest; +} + +/** + * Compare the built bundle against its sources. + * + * Unknown is not stale. A packaged install has no `gui/src` beside it, and a missing bundle is a + * different condition with its own message elsewhere; neither should produce a rebuild warning. + */ +export function inspectGuiBundleFreshness(input: { + bundlePath: string | null; + sourcePath: string; +}): GuiBundleFreshness { + const bundleModifiedMs = input.bundlePath === null ? null : newestModifiedMs(input.bundlePath); + const sourceModifiedMs = newestModifiedMs(input.sourcePath); + const stale = bundleModifiedMs !== null + && sourceModifiedMs !== null + && sourceModifiedMs > bundleModifiedMs; + return { + bundlePath: input.bundlePath, + bundleModifiedMs, + sourceModifiedMs, + stale, + }; +} + +/** Operator-facing lines for a stale bundle; empty when the bundle is current or unknown. */ +export function staleGuiBundleLines(freshness: GuiBundleFreshness): string[] { + if (!freshness.stale) return []; + return [ + "Dashboard bundle is older than the GUI sources, so the page is missing everything built since.", + "Rebuild it with: bun run build:gui", + ]; +} diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index 9a6439f0971..3057dc748ee 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -20,7 +20,7 @@ const MIME_TYPES: Record = { */ const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/; -function findGuiDist(): string | null { +export function findGuiDist(): string | null { const candidates = [ process.env.OPENCODEX_GUI_DIST, ...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 17dadddff96..6a492dc56d1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -409,6 +409,7 @@ "codex-shim-readiness.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", + "server-gui-bundle-freshness.test.ts": "server", "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", diff --git a/tests/server/server-gui-bundle-freshness.test.ts b/tests/server/server-gui-bundle-freshness.test.ts new file mode 100644 index 00000000000..16860c1e08b --- /dev/null +++ b/tests/server/server-gui-bundle-freshness.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + inspectGuiBundleFreshness, + newestModifiedMs, + staleGuiBundleLines, +} from "../../src/server/gui-freshness"; + +/** Write `name` under `root` with an explicit mtime, creating parents as needed. */ +function writeAt(root: string, name: string, secondsSinceEpoch: number): string { + const path = join(root, name); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "x"); + utimesSync(path, secondsSinceEpoch, secondsSinceEpoch); + return path; +} + +describe("gui bundle freshness", () => { + test("sources newer than the bundle are stale, and the advice names the rebuild", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "dist/assets/index-AAAAAAAA.js", 1_000_000); + writeAt(root, "src/pages/Usage.tsx", 2_000_000); + + const freshness = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + + expect(freshness.stale).toBe(true); + const lines = staleGuiBundleLines(freshness); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).toContain("bun run build:gui"); + }); + + test("a bundle rebuilt after its sources is not stale and says nothing", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "src/pages/Usage.tsx", 1_000_000); + writeAt(root, "dist/assets/index-BBBBBBBB.js", 2_000_000); + + const freshness = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + + expect(freshness.stale).toBe(false); + expect(staleGuiBundleLines(freshness)).toEqual([]); + }); + + test("an unknown side is never stale, because a packaged install ships no sources", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "dist/assets/index-CCCCCCCC.js", 2_000_000); + + const missingSources = inspectGuiBundleFreshness({ + bundlePath: join(root, "dist"), + sourcePath: join(root, "src"), + }); + expect(missingSources.sourceModifiedMs).toBeNull(); + expect(missingSources.stale).toBe(false); + + const missingBundle = inspectGuiBundleFreshness({ + bundlePath: null, + sourcePath: join(root, "dist"), + }); + expect(missingBundle.bundleModifiedMs).toBeNull(); + expect(missingBundle.stale).toBe(false); + }); + + test("node_modules cannot make a tree look newer than its own files", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-gui-fresh-")); + writeAt(root, "src/pages/Usage.tsx", 1_000_000); + writeAt(root, "src/node_modules/dep/index.js", 9_000_000); + + expect(newestModifiedMs(join(root, "src"))).toBe(1_000_000_000); + }); +});