codeburn/tests/menubar-relaunch.test.ts
Resham Joshi 151509a1fb
fix(cache-lock): coalesce heartbeat ticks; make time-sensitive tests deterministic (#1559)
* fix(cache-lock): coalesce heartbeat ticks so the fence never waits behind a backlog

Each interval tick queued a full heartbeat in the owner serializer, so an
interval firing faster than a tick completes grew the queue without bound and
verifyStillOwner waited behind all of it. The 1ms-heartbeat fence test hit its
30s timeout on a loaded runner and retried up to 10 times, which is the silent
cache-lock suite hang in CI.

* test(menubar): hold off the helper's SIGTERM until the stand-in app records its status

The relaunch helper signals the app as soon as the CLI child is reaped, which
could land between the stand-in's fopen and fclose and leave an empty status
file. Also wait for the recorder's full line instead of a fixed sleep.

* test: pin day-boundary tests so they pass at any time of day

CLI children that query "today" now run in a fixed-offset zone where it is
about noon on the UTC date (tests/fixtures/noon-tz.ts), so fixtures a little in
the past are always today; the old clamp to UTC midnight put them in the future
for the first minutes of the day and zeroed the counts. The month-scoped budget
and project-filter fixtures get the same treatment (the latter also failed on
days 1-5 of every month), the cursor lookback case derives its start from now
instead of a date that ages out on 1 Oct, and the renderer tests that format a
stamp from a few hours back pin the clock to mid-day. The session drawer date
is formatted in the local zone, so its expectation is too.

* test: derive dated fixtures from the clock so rolling windows never age them out

The six-month --period all window and the sync ledger's six-month prune
dropped fixtures fixed at April-July 2026 from 1 Nov 2026 on. CLI fixtures now
sit 30 days back (utcDaysAgo), the ledger and push tests pin Date to July 2026,
the --to-only range uses yesterday, and the date-picker tests pin mid-month so
two days back is on the calendar they open (they failed on the 1st and 2nd of
every month). The noon-tz helper moves to tests/fixtures/clock.ts.

* test(desktop): give spawned stand-ins room to boot before the silence window

The watchdog and graceful-kill tests used 1-1.5s silence windows, and the
window before a child's first byte includes node's boot. On a loaded machine
the child was signalled before it installed its handler or wrote its byte.
The windows are now 3s and the last-byte test measures from the byte the child
stamps. The killAll and fallback-scheduler tests wait on their condition with
a 15s deadline instead of a 2-3s one.
2026-09-25 05:06:43 +02:00

151 lines
6.5 KiB
TypeScript

import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { once } from 'node:events'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ancestorsOf, replaceAndRelaunch, runningAppPids } from '../src/menubar-installer.js'
describe('ancestorsOf', () => {
const ps = [' 1 0', ' 50 1', ' 60 50', ' 70 60', ' 80 1', ''].join('\n')
it('walks the parent chain up to launchd', () => {
expect([...ancestorsOf(70, ps)]).toEqual([60, 50])
})
it('does not count unrelated processes', () => {
expect(ancestorsOf(70, ps).has(80)).toBe(false)
expect(ancestorsOf(80, ps).size).toBe(0)
})
it('stops on a cycle and on an unknown pid', () => {
expect([...ancestorsOf(2, ' 2 3\n 3 2\n')]).toEqual([3, 2])
expect(ancestorsOf(999, ps).size).toBe(0)
})
})
// A stand-in for the menubar app: a real process with a unique name, so the pgrep, kill and
// ancestry code runs for real without going near the installed CodeBurnMenubar. It runs an
// optional command as its child (the Update button's `codeburn menubar --force`), records the
// exit status, then idles until signalled. SIGTERM is held off until the status is on disk:
// the helper signals the app as soon as its child is reaped, and a real app handles that
// termination itself rather than dying between two lines of C.
const DUMMY_C = `#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char **argv) {
if (argc > 2) {
sigset_t term;
sigemptyset(&term);
sigaddset(&term, SIGTERM);
sigprocmask(SIG_BLOCK, &term, NULL);
int status = system(argv[2]);
FILE *f = fopen(argv[1], "w");
fprintf(f, "%d", WEXITSTATUS(status));
fclose(f);
sigprocmask(SIG_UNBLOCK, &term, NULL);
}
for (;;) pause();
}
`
const canRun = process.platform === 'darwin' && spawnSync('cc', ['--version']).status === 0
describe.skipIf(!canRun)('replaceAndRelaunch with a stand-in app', () => {
vi.setConfig({ testTimeout: 60_000 })
const cleanup: Array<() => Promise<void>> = []
afterEach(async () => {
for (const fn of cleanup.splice(0)) await fn()
})
async function setup() {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-relaunch-'))
const name = `cbRl${Math.random().toString(16).slice(2, 8)}`
const bin = join(dir, name)
await writeFile(join(dir, 'dummy.c'), DUMMY_C)
expect(spawnSync('cc', ['-o', bin, join(dir, 'dummy.c')]).status).toBe(0)
const staged = join(dir, 'staged', 'CodeBurnMenubar.app')
const target = join(dir, 'apps', 'CodeBurnMenubar.app')
await mkdir(staged, { recursive: true })
await mkdir(target, { recursive: true })
await writeFile(join(staged, 'v'), 'new')
await writeFile(join(target, 'v'), 'old')
const procs: ChildProcess[] = []
cleanup.push(async () => {
for (const p of procs) if (p.exitCode === null && p.signalCode === null) p.kill('SIGKILL')
await rm(dir, { recursive: true, force: true })
})
const start = async (args: string[]) => {
const p = spawn(bin, args, { stdio: 'ignore' })
procs.push(p)
await once(p, 'spawn')
return p
}
const marker = join(dir, 'launched')
// Stands in for `open`: records whether the app was still alive and which bundle was in
// place at the moment of the launch.
const launchCommand = (pid: number) => ['/bin/sh', '-c', 'kill -0 "$1" 2>/dev/null && s=alive || s=dead; echo "$s $(cat "$3/v")" > "$2"', 'launch', String(pid), marker]
return { dir, name, staged, target, marker, start, launchCommand }
}
async function waitFor(path: string, ms = 30_000): Promise<string> {
const until = Date.now() + ms
for (;;) {
const text = existsSync(path) ? await readFile(path, 'utf-8') : ''
if (text.endsWith('\n')) return text.trim()
if (Date.now() > until) throw new Error(`timed out waiting for ${path}`)
await new Promise(r => setTimeout(r, 100))
}
}
it('from a terminal: stops the app, swaps, then launches, before returning', async () => {
const t = await setup()
const app = await t.start([])
expect(await runningAppPids(t.name)).toEqual([app.pid])
const exited = once(app, 'exit')
await replaceAndRelaunch(t.staged, t.target, true, { processName: t.name, launchCommand: t.launchCommand(app.pid!) })
expect(await readFile(t.marker, 'utf-8')).toBe('dead new\n')
expect((await exited)[1]).toBe('SIGTERM')
})
it('from the app itself: finds its ancestor, swaps while it runs, exits, then the helper stops and relaunches it', async () => {
const t = await setup()
const installer = pathToFileURL(join(process.cwd(), 'src', 'menubar-installer.ts')).href
const swapped = join(t.dir, 'swapped')
const child = join(t.dir, 'child.mts')
const status = join(t.dir, 'status')
const appPid = join(t.dir, 'apppid')
// The dummy's pid is only known once it runs, so the child reads it from its parent.
await writeFile(child, `
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { replaceAndRelaunch } from ${JSON.stringify(installer)}
while (!existsSync(${JSON.stringify(appPid)})) await new Promise(r => setTimeout(r, 50))
const app = Number(readFileSync(${JSON.stringify(appPid)}, 'utf-8'))
await replaceAndRelaunch(${JSON.stringify(t.staged)}, ${JSON.stringify(t.target)}, true, {
processName: ${JSON.stringify(t.name)},
launchCommand: ${JSON.stringify(t.launchCommand(0).slice(0, 3))}.concat(['launch', String(app), ${JSON.stringify(t.marker)}]),
})
let alive = true
try { process.kill(app, 0) } catch { alive = false }
writeFileSync(${JSON.stringify(swapped)}, (alive ? 'alive ' : 'dead ') + readFileSync(${JSON.stringify(join(t.target, 'v'))}, 'utf-8'))
`)
const cmd = `exec "${process.execPath}" --import tsx "${child}"`
const app = await t.start([status, cmd])
await writeFile(appPid, String(app.pid))
const exited = once(app, 'exit')
expect(await waitFor(t.marker)).toBe('dead new')
// The CLI finished, successfully, with the app still alive to read its exit status...
expect(await readFile(status, 'utf-8')).toBe('0')
// ...and the bundle was already swapped while the app was running.
expect(await readFile(swapped, 'utf-8')).toBe('alive new')
expect((await exited)[1]).toBe('SIGTERM')
})
})