diff --git a/packages/stim-cli/README.md b/packages/stim-cli/README.md index 67a60233..29268bbf 100644 --- a/packages/stim-cli/README.md +++ b/packages/stim-cli/README.md @@ -31,6 +31,11 @@ stim logs --errors stim stop ``` +For `stim logs --errors`, a clean check requires exit code 0 and no matching +errors in the captured logs. Exit code 0 alone means the query succeeded, even +when it prints errors; an empty result does not prove launch or log capture +succeeded. + Use `stim doctor --platform ios` or `stim doctor --platform android` when only one native platform is in scope; shared project checks still run. diff --git a/packages/stim-cli/src/__tests__/guide.test.ts b/packages/stim-cli/src/__tests__/guide.test.ts index 4b916355..a78f1875 100644 --- a/packages/stim-cli/src/__tests__/guide.test.ts +++ b/packages/stim-cli/src/__tests__/guide.test.ts @@ -1025,7 +1025,9 @@ test('the agent guide carries the normal workflow and safety rules', () => { expect(agent).toMatch(/give the user one compact result: exact device,[\s\S]*total duration/); expect(agent).toMatch(/whether stim logs\s+--errors passed/); expect(agent).toMatch(/Do not repeat the\s+phase transcript/); - expect(agent).toMatch(/Exit code 0 from logs --errors is the pass condition/); + expect(agent).toMatch(/requires exit code 0 AND no matching errors in\s+captured logs/); + expect(agent).toMatch(/Exit code 0 alone means the query succeeded, even when errors\s+were printed/); + expect(agent).toMatch(/does not prove launch or log\s+capture succeeded/); expect(agent).toContain('No matching log records'); expect(agent).toContain('stim reload'); expect(normalWorkflow).not.toContain('stim reload'); @@ -1035,6 +1037,18 @@ test('the agent guide carries the normal workflow and safety rules', () => { expect(agent).toMatch(/Ordinary stim stop and an authorized clean\s+stim worktree remove do not need/); }); +test('the logs and lifecycle guides distinguish query success from a clean captured timeline', () => { + const logs = renderTopic('logs'); + assert(logs); + expect(logs).toContain('EXIT 0 MEANS THE QUERY SUCCEEDED, whether or not records matched'); + expect(logs).toMatch(/requires exit code 0 AND no matching errors in\s+captured logs/); + expect(logs).toContain('a workspace with no log directory also returns an empty result'); + const lifecycle = renderTopic('lifecycle'); + assert(lifecycle); + expect(lifecycle).toContain('Check captured errors: require exit 0 AND no matching errors'); + expect(lifecycle).toContain('Exit 0 alone means the query succeeded, even when it printed errors'); +}); + test('the logs guide keeps Expo and bare React Native stack context on one human error record', () => { const logs = renderTopic('logs'); assert(logs); diff --git a/packages/stim-cli/src/commands/guide.ts b/packages/stim-cli/src/commands/guide.ts index 41919e5d..0bf3446c 100644 --- a/packages/stim-cli/src/commands/guide.ts +++ b/packages/stim-cli/src/commands/guide.ts @@ -89,9 +89,11 @@ RULES DURING THE LOOP still building" means Metro has not finished; wait and query the logs. For launch UNVERIFIED, follow the printed remedy before claiming success. JSON reports these as true, "bundling", and "unverified" in launched. -- Exit code 0 from logs --errors is the pass condition. Human output can show - "No matching log records" on stderr. JSON mode prints zero bytes when no - records match. Do not read the NDJSON files directly. +- A clean logs --errors check requires exit code 0 AND no matching errors in + captured logs. Exit code 0 alone means the query succeeded, even when errors + were printed. Human output shows "No matching log records" on stderr for + zero matches; JSON mode prints zero bytes. This does not prove launch or log + capture succeeded. Do not read the NDJSON files directly. - Use stim status when resuming a workspace or recovering missing device, port, server, or build facts. A normal start and platform run already print them. Use stim doctor when a build is unexpectedly slow or the environment @@ -660,9 +662,12 @@ Reads every *.ndjson file in the global workspace logs directory, merges them in ordered by timestamp, prints what matches, and EXITS. The file set is discovered, not enumerated. -NOTHING MATCHING IS EXIT 0. \`stim logs --errors\` finding nothing is the -pass condition of a build loop, so an empty result must never read as a -failure. Precisely what that looks like: STDOUT IS EMPTY, exit code 0, and +EXIT 0 MEANS THE QUERY SUCCEEDED, whether or not records matched. A clean +\`stim logs --errors\` check requires exit code 0 AND no matching errors in +captured logs. An empty result does not prove launch or log capture succeeded; +a workspace with no log directory also returns an empty result. + +For zero matches: STDOUT IS EMPTY, exit code 0, and one dim note on STDERR reading \`No matching log records in \` (human mode only -- \`--json\` prints nothing at all, on either stream). The only exit-1 paths are a malformed query and no project. @@ -1719,8 +1724,9 @@ STIM_CONFIG_CORRUPT ("Stim config at is not valid JSON") install from cache (3s) launch com.example.app (1s) - # 4. Did it work? Exit 0 is the pass condition. Human mode prints a - # "No matching log records" note on stderr when the result is clean. + # 4. Check captured errors: require exit 0 AND no matching errors. + # Human mode prints "No matching log records" on stderr for zero matches. + # Exit 0 alone means the query succeeded, even when it printed errors. stim logs --errors # 5. Edit the JS. Fast Refresh applies it; no Stim command is involved. diff --git a/test/e2e/logs.e2e.js b/test/e2e/logs.e2e.js new file mode 100644 index 00000000..5d1fe9f6 --- /dev/null +++ b/test/e2e/logs.e2e.js @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { workspaceLogsDir } from '../../packages/stim-cli/src/paths.ts'; + +const CLI = fileURLToPath(new URL('../../packages/stim-cli/bin/cli.ts', import.meta.url)); +let home; +let project; + +beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), 'stim-logs-e2e-home-'))); + process.env.STIM_HOME = home; + project = realpathSync(mkdtempSync(join(tmpdir(), 'stim-logs-e2e-project-'))); + writeFileSync(join(project, 'package.json'), '{"name":"logs-fixture","private":true}\n'); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + delete process.env.STIM_HOME; +}); + +test('an empty error query exits 0, with a human-only note on stderr', () => { + writeLogs([{ ts: 1, src: 'metro', level: 'info', msg: 'bundling' }]); + const human = run(); + assert.equal(human.status, 0, human.stderr); + assert.equal(human.stdout, ''); + assert.match(human.stderr, /No matching log records/); + + const json = run(['--json']); + assert.equal(json.status, 0, json.stderr); + assert.equal(json.stdout, ''); + assert.equal(json.stderr, ''); +}); + +test('matching errors also exit 0, so a successful query alone is not a clean check', () => { + const error = { ts: 2, src: 'metro', level: 'error', msg: 'App failed to render' }; + writeLogs([{ ts: 1, src: 'metro', level: 'info', msg: 'bundle completed', marker: true }, error]); + const human = run(); + assert.equal(human.status, 0, human.stderr); + assert.match(human.stdout, /App failed to render/); + assert.doesNotMatch(human.stderr, /No matching log records/); + + const json = run(['--json']); + assert.equal(json.status, 0, json.stderr); + assert.deepEqual(json.stdout.trim().split('\n').map(JSON.parse), [error]); + assert.equal(json.stderr, ''); +}); + +test('no log directory is a successful empty query without evidence of capture', () => { + const result = run(['--json']); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); +}); + +test('an invalid query exits 1 with empty stdout and a diagnostic on stderr', () => { + const result = run(['--json', '--source', 'unknown']); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /Unknown --source value/); +}); + +function writeLogs(records) { + const dir = workspaceLogsDir(project); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'metro.ndjson'), records.map((record) => `${JSON.stringify(record)}\n`).join('')); +} + +function run(args = []) { + return spawnSync(process.execPath, [CLI, 'logs', '--errors', ...args], { + cwd: project, + env: process.env, + encoding: 'utf-8', + }); +} diff --git a/website/docs/dev-server-and-logs.md b/website/docs/dev-server-and-logs.md index 6c2c7c80..c4069c1d 100644 --- a/website/docs/dev-server-and-logs.md +++ b/website/docs/dev-server-and-logs.md @@ -35,8 +35,12 @@ The merged timeline includes Metro, client, device, and build records. Logs live in the global workspace directory under `$STIM_HOME/workspaces`, not in the project checkout. -No matching records is a successful result with exit code 0. JSON mode writes -NDJSON and writes zero bytes for zero matches. +Exit code 0 means the query succeeded, including when it prints errors. A clean +`stim logs --errors` check requires exit code 0 and no matching errors in the +captured logs. Human mode prints `No matching log records` on stderr for zero +matches; JSON mode writes NDJSON and writes zero bytes for zero matches. An +empty result does not prove launch or log capture succeeded: a workspace with +no log directory also returns an empty result. `stim stop` ends the supervisor and log collectors. It also frees the reserved port and shuts down the owned local device. diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 24184d7c..e6ab4a82 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -47,7 +47,7 @@ The agent normally runs: code={`stim doctor # inspect the main checkout and warm-state gaps stim start # start this workspace's dev server stim ios # build or restore, install, launch, and verify -stim logs --errors # no matching records means the launch is healthy +stim logs --errors # check for errors in the captured logs stim stop # release the live environment`} />