@sdods/cli 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,38 @@ export interface BrowserStatus {
9
9
  executable: string | null;
10
10
  playwrightVersion: string;
11
11
  }
12
- export declare function browserStatuses(names?: string[]): Promise<BrowserStatus[]>;
12
+ /**
13
+ * Whether the download that produced `executable` finished.
14
+ *
15
+ * Playwright writes `INSTALLATION_COMPLETE` last. An executable without it is a download that was
16
+ * cut off, or one an antivirus scanner is still holding, and it does not launch reliably. Headless
17
+ * Chromium runs launch the separate headless shell of the same revision, so that must be complete
18
+ * too; a cache holding only one of the two fails every run with "Executable doesn't exist".
19
+ *
20
+ * A path outside Playwright's cache layout (a custom `executablePath`) has no marker to check, so
21
+ * the executable existing is all this can say.
22
+ */
23
+ export declare function installationComplete(executable: string): boolean;
24
+ export declare function browserStatuses(names?: string[], opts?: {
25
+ cwd?: string;
26
+ }): Promise<BrowserStatus[]>;
27
+ /**
28
+ * Make sure every downloaded engine a run needs is on disk, installing the missing ones when
29
+ * `install` is set.
30
+ *
31
+ * This is what stands between a run and Playwright's "Executable doesn't exist … run npx
32
+ * playwright install". That advice is wrong for SDODS: run from anywhere but the workspace it
33
+ * installs a different Playwright's revision, and without PLAYWRIGHT_BROWSERS_PATH it installs
34
+ * into a folder the desktop app never reads. Installing here, from the workspace, with the
35
+ * environment the run itself inherits, lands the exact revision where the runner will look, on
36
+ * every OS.
37
+ *
38
+ * Channel browsers (Edge) are a system install and stay with the check in `run`.
39
+ */
40
+ export declare function ensureBrowsers(names: string[], opts: {
41
+ cwd: string;
42
+ install: boolean;
43
+ }): Promise<string[]>;
13
44
  /** Install browser engines and channels (used by `browsers install`, `doctor --fix`, `init`). */
14
45
  export declare function installBrowsers(opts?: {
15
46
  browsers?: string[];
@@ -1,12 +1,12 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
- import { join } from 'node:path';
3
+ import { basename, dirname, join } from 'node:path';
4
4
  import { execa } from 'execa';
5
5
  import pc from 'picocolors';
6
6
  import { BrowserSchema } from '@sdods/contracts';
7
7
  import { SdodsError } from '@sdods/core';
8
8
  import { createContext } from '../context.js';
9
- import { collect, json, ok, table } from '../ui.js';
9
+ import { collect, info, json, ok, table, warn } from '../ui.js';
10
10
  // Typed against BrowserName (not `string`) so a new browser is a compile error here rather than a
11
11
  // silent fall-through to chromium.
12
12
  const ENGINE_OF = {
@@ -54,30 +54,83 @@ function channelExecutable(channel) {
54
54
  }
55
55
  return null;
56
56
  }
57
- export async function browserStatuses(names = DOWNLOADED_BROWSERS) {
58
- const require = createRequire(import.meta.url);
59
- let pw;
60
- let version = 'unknown';
61
- try {
62
- pw = await import('playwright-core');
63
- version = require('playwright-core/package.json').version;
57
+ /**
58
+ * The playwright-core the RUNNER will launch, not the CLI's own copy.
59
+ *
60
+ * `@sdods/cli` depends on playwright-core, and the workspace depends on `@playwright/test`, which
61
+ * brings its own. When the two versions differ they want different browser revisions, and asking
62
+ * the CLI's copy would report `chromium-1243` present while the runner looks for `chromium-1250`.
63
+ * So resolve through the workspace's `@playwright/test` first, the same package `npx playwright
64
+ * install` runs from, and fall back to the CLI's copy only when there is no workspace install.
65
+ */
66
+ function loadPlaywrightCore(cwd) {
67
+ const attempts = [];
68
+ if (cwd) {
69
+ attempts.push(() => {
70
+ const ws = createRequire(join(cwd, 'package.json'));
71
+ const test = createRequire(ws.resolve('@playwright/test/package.json'));
72
+ return createRequire(test.resolve('playwright/package.json'));
73
+ });
74
+ attempts.push(() => createRequire(join(cwd, 'package.json')));
64
75
  }
65
- catch {
66
- return names.map((name) => ({
67
- name,
68
- engine: ENGINE_OF[name] ?? 'chromium',
69
- channel: CHANNEL_OF[name] ?? null,
70
- installed: false,
71
- executable: null,
72
- playwrightVersion: version,
73
- }));
76
+ attempts.push(() => createRequire(import.meta.url));
77
+ for (const attempt of attempts) {
78
+ try {
79
+ const req = attempt();
80
+ const pw = req('playwright-core');
81
+ const { version } = req('playwright-core/package.json');
82
+ return { pw, version };
83
+ }
84
+ catch {
85
+ // try the next location
86
+ }
74
87
  }
88
+ return null;
89
+ }
90
+ /** `chromium-1243`, `firefox-1543`, `webkit-2359`: the folder Playwright downloads a revision into. */
91
+ const REVISION_DIR = /^(chromium|firefox|webkit)-(\d+)$/;
92
+ /**
93
+ * Whether the download that produced `executable` finished.
94
+ *
95
+ * Playwright writes `INSTALLATION_COMPLETE` last. An executable without it is a download that was
96
+ * cut off, or one an antivirus scanner is still holding, and it does not launch reliably. Headless
97
+ * Chromium runs launch the separate headless shell of the same revision, so that must be complete
98
+ * too; a cache holding only one of the two fails every run with "Executable doesn't exist".
99
+ *
100
+ * A path outside Playwright's cache layout (a custom `executablePath`) has no marker to check, so
101
+ * the executable existing is all this can say.
102
+ */
103
+ export function installationComplete(executable) {
104
+ if (!existsSync(executable))
105
+ return false;
106
+ let dir = dirname(executable);
107
+ while (!REVISION_DIR.test(basename(dir))) {
108
+ const parent = dirname(dir);
109
+ if (parent === dir)
110
+ return true;
111
+ dir = parent;
112
+ }
113
+ const [, engine, revision] = REVISION_DIR.exec(basename(dir));
114
+ if (!existsSync(join(dir, 'INSTALLATION_COMPLETE')))
115
+ return false;
116
+ if (engine !== 'chromium')
117
+ return true;
118
+ const shell = join(dirname(dir), `chromium_headless_shell-${revision}`);
119
+ return existsSync(join(shell, 'INSTALLATION_COMPLETE'));
120
+ }
121
+ export async function browserStatuses(names = DOWNLOADED_BROWSERS, opts = {}) {
122
+ const loaded = loadPlaywrightCore(opts.cwd);
123
+ const version = loaded?.version ?? 'unknown';
75
124
  return names.map((name) => {
76
125
  const engine = ENGINE_OF[name] ?? 'chromium';
77
126
  const channel = CHANNEL_OF[name] ?? null;
78
127
  let executable;
79
128
  try {
80
- executable = channel ? channelExecutable(channel) : pw[engine].executablePath();
129
+ executable = channel
130
+ ? channelExecutable(channel)
131
+ : loaded
132
+ ? loaded.pw[engine].executablePath()
133
+ : null;
81
134
  }
82
135
  catch {
83
136
  executable = null;
@@ -86,12 +139,58 @@ export async function browserStatuses(names = DOWNLOADED_BROWSERS) {
86
139
  name,
87
140
  engine,
88
141
  channel,
89
- installed: Boolean(executable && existsSync(executable)),
142
+ installed: Boolean(executable && (channel ? existsSync(executable) : installationComplete(executable))),
90
143
  executable,
91
144
  playwrightVersion: version,
92
145
  };
93
146
  });
94
147
  }
148
+ /**
149
+ * Make sure every downloaded engine a run needs is on disk, installing the missing ones when
150
+ * `install` is set.
151
+ *
152
+ * This is what stands between a run and Playwright's "Executable doesn't exist … run npx
153
+ * playwright install". That advice is wrong for SDODS: run from anywhere but the workspace it
154
+ * installs a different Playwright's revision, and without PLAYWRIGHT_BROWSERS_PATH it installs
155
+ * into a folder the desktop app never reads. Installing here, from the workspace, with the
156
+ * environment the run itself inherits, lands the exact revision where the runner will look, on
157
+ * every OS.
158
+ *
159
+ * Channel browsers (Edge) are a system install and stay with the check in `run`.
160
+ */
161
+ export async function ensureBrowsers(names, opts) {
162
+ const wanted = [...new Set(names)].filter((n) => !CHANNEL_OF[n]);
163
+ if (!wanted.length)
164
+ return [];
165
+ const missing = (await browserStatuses(wanted, { cwd: opts.cwd })).filter((s) => !s.installed);
166
+ if (!missing.length)
167
+ return [];
168
+ // One engine can back several browsers (mobile-chrome is chromium); install each engine once.
169
+ const engines = [...new Set(missing.map((s) => s.engine))];
170
+ if (!opts.install) {
171
+ throw new SdodsError('NOT_SUPPORTED', `The ${engines.join(', ')} test browser${engines.length > 1 ? 's are' : ' is'} not installed.`, {
172
+ hint: `Run \`sdods browsers install ${engines.map((e) => `-b ${e}`).join(' ')}\` from the workspace, ` +
173
+ 'or pass --install-browsers to install missing browsers before the run.',
174
+ exitCode: 2,
175
+ });
176
+ }
177
+ info(`Installing the ${engines.join(', ')} test browser${engines.length > 1 ? 's' : ''} for this run…`);
178
+ await installBrowsers({ browsers: engines, cwd: opts.cwd });
179
+ const still = (await browserStatuses(engines, { cwd: opts.cwd })).filter((s) => !s.installed);
180
+ if (still.length) {
181
+ throw new SdodsError('NOT_SUPPORTED', `Installing ${still.map((s) => s.name).join(', ')} did not complete.`, {
182
+ hint: 'Check the network or proxy (HTTPS_PROXY) and any antivirus quarantine, then run ' +
183
+ `\`sdods browsers install ${still.map((s) => `-b ${s.name}`).join(' ')}\`.`,
184
+ exitCode: 2,
185
+ });
186
+ }
187
+ if (process.platform === 'linux') {
188
+ warn('If the browser then fails to launch with missing libraries, run ' +
189
+ `\`sudo npx playwright install-deps ${engines.join(' ')}\` once on this machine.`);
190
+ }
191
+ ok(`Installed ${engines.join(', ')}`);
192
+ return engines;
193
+ }
95
194
  /** Install browser engines and channels (used by `browsers install`, `doctor --fix`, `init`). */
96
195
  export async function installBrowsers(opts = {}) {
97
196
  const names = (opts.browsers?.length ? opts.browsers : DOWNLOADED_BROWSERS);
@@ -183,7 +282,7 @@ export function register(program) {
183
282
  : DOWNLOADED_BROWSERS;
184
283
  for (const b of names)
185
284
  BrowserSchema.parse(b);
186
- const statuses = await browserStatuses(names);
285
+ const statuses = await browserStatuses(names, { cwd: ctx.rootDir });
187
286
  if (ctx.opts.json)
188
287
  return json(statuses);
189
288
  table(statuses.map((s) => ({
@@ -37,7 +37,7 @@ export function registerDoctorCommand(program) {
37
37
  fix: `npm i -D @playwright/test@^${MIN_PLAYWRIGHT_FOR_STEP_RESULTS} (or bun add -d)`,
38
38
  });
39
39
  }
40
- const browsers = await browserCheck();
40
+ const browsers = await browserCheck(ctx.rootDir);
41
41
  checks.push(...browsers);
42
42
  if (opts.fix && browsers.some((b) => !b.ok)) {
43
43
  out(pc.cyan('Installing browsers…'));
@@ -239,11 +239,6 @@ function tokenMatrix(cliStatus, localLlm) {
239
239
  requirement: 'optional',
240
240
  present: has('SDODS_TOKEN'),
241
241
  detail: 'self-issued, free: MCP over HTTP and CI result ingest (`sdods tokens create`)',
242
- }, {
243
- name: 'FIREBASE_SERVICE_ACCOUNT_AUTOMAX_DOCS',
244
- requirement: 'ci-only',
245
- present: has('FIREBASE_SERVICE_ACCOUNT_AUTOMAX_DOCS'),
246
- detail: 'GitHub secret for the docs deploy workflow',
247
242
  }, {
248
243
  name: 'NPM_TOKEN',
249
244
  requirement: 'ci-only',
@@ -351,11 +346,11 @@ async function versionCheck(name, args, fix, bin = name) {
351
346
  return { name, ok: false, detail: 'not found', fix };
352
347
  }
353
348
  }
354
- async function browserCheck() {
349
+ async function browserCheck(cwd) {
355
350
  // Reuses `browsers list`'s detection so the two commands can never disagree about what is
356
351
  // installed — and so channel browsers are probed by path rather than by the bundled engine's
357
352
  // executable, which exists whether or not the branded browser does.
358
- const statuses = await browserStatuses(['chromium', 'edge', 'firefox', 'webkit']);
353
+ const statuses = await browserStatuses(['chromium', 'edge', 'firefox', 'webkit'], { cwd });
359
354
  return statuses.map((s) => ({
360
355
  name: `browser:${s.name}`,
361
356
  ok: s.installed,
@@ -41,6 +41,7 @@ export interface RunFlags {
41
41
  allowEmpty?: boolean;
42
42
  notify?: boolean;
43
43
  allowPoolContention?: boolean;
44
+ installBrowsers?: boolean;
44
45
  }
45
46
  export declare function register(program: Command): void;
46
47
  export declare function runCommand(flags: RunFlags, cmd: Command): Promise<number>;
@@ -6,7 +6,7 @@ import { RecordingModeSchema, newRunId, runFiles, } from '@sdods/contracts';
6
6
  import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, collectGateEvidence, effectiveWorkers, evaluateGates, formatFindings, hasGates, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, parseTagExpr, poolAccountsFor, poolDemandByRole, poolTooSmallError, redactRunTraces, serializeCliOverrides, setupTierOf, } from '@sdods/core';
7
7
  import { analyzeChangeImpact } from '@sdods/mcp';
8
8
  import { createContext } from '../context.js';
9
- import { browserStatuses } from './browsers.js';
9
+ import { browserStatuses, ensureBrowsers } from './browsers.js';
10
10
  import { gateFailedError, printGates } from '../gates.js';
11
11
  import { maybeNotify, notifyRun } from '../notify.js';
12
12
  import { installedPlaywrightVersion, stepResultsWarning } from '../runner-compat.js';
@@ -36,6 +36,7 @@ function addRunOptions(cmd) {
36
36
  .option('--process <name>', 'run a named process (recipe) from the project or workspace')
37
37
  .option('--project-matrix', 'run every browser declared in the project yaml')
38
38
  .option('--device <name>', 'device name for mobile emulation, e.g. "iPhone 15"')
39
+ .option('--install-browsers', 'download any test browser the run needs that is missing (also SDODS_AUTO_INSTALL_BROWSERS=1)')
39
40
  .option('--headed', 'run headed')
40
41
  .option('-w, --workers <n>', 'parallel workers', parseIntFlag('workers'))
41
42
  .option('--allow-pool-contention', 'run even when a @user: role has fewer pool accounts than the workers that need it')
@@ -138,7 +139,7 @@ export async function runCommand(flags, cmd) {
138
139
  // selected browser here would fail runs that would have worked.
139
140
  const usesBrowser = !layers.length || layers.some((l) => l !== 'api');
140
141
  const missing = usesBrowser
141
- ? (await browserStatuses(browsers)).filter((s) => s.channel && !s.installed)
142
+ ? (await browserStatuses(browsers, { cwd: ctx.rootDir })).filter((s) => s.channel && !s.installed)
142
143
  : [];
143
144
  if (missing.length) {
144
145
  const names = missing.map((s) => s.name);
@@ -313,6 +314,17 @@ export async function runCommand(flags, cmd) {
313
314
  exitCode: 2,
314
315
  });
315
316
  }
317
+ // Every run target launches a browser, including @api ones: the BDD fixtures are shared across
318
+ // layers, so an api target opens the runner's default engine (chromium). Checking here, against
319
+ // the targets actually generated, catches a missing or half-downloaded engine before bddgen, and
320
+ // either installs it (desktop app, --install-browsers) or stops with the SDODS command rather
321
+ // than Playwright's "npx playwright install", which installs to the wrong place.
322
+ if (!flags.list) {
323
+ await ensureBrowsers(runnerProjects.map((p) => p.browser ?? 'chromium'), {
324
+ cwd: ctx.rootDir,
325
+ install: Boolean(flags.installBrowsers) || process.env.SDODS_AUTO_INSTALL_BROWSERS === '1',
326
+ });
327
+ }
316
328
  // Recorded specs are plain runner tests: no Gherkin to generate.
317
329
  const recordedOnly = runnerProjects.every((p) => p.layer === 'recorded');
318
330
  if (!recordedOnly) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -25,8 +25,8 @@
25
25
  "node": ">=22"
26
26
  },
27
27
  "dependencies": {
28
- "@sdods/contracts": "0.11.3",
29
- "@sdods/core": "0.11.3",
28
+ "@sdods/contracts": "0.12.0",
29
+ "@sdods/core": "0.12.0",
30
30
  "commander": "^15.0.0",
31
31
  "execa": "^10.0.1",
32
32
  "picocolors": "^1.1.1",
@@ -35,12 +35,12 @@
35
35
  "zod": "^4.6.5",
36
36
  "@playwright/test": "^1.63.0",
37
37
  "playwright-core": "^1.63.0",
38
- "@sdods/integrations": "0.11.3",
39
- "@sdods/mcp": "0.11.3",
40
- "@sdods/agents": "0.11.3",
41
- "@sdods/db": "0.11.3",
38
+ "@sdods/integrations": "0.12.0",
39
+ "@sdods/mcp": "0.12.0",
40
+ "@sdods/agents": "0.12.0",
41
+ "@sdods/db": "0.12.0",
42
42
  "csv-parse": "^7.0.2",
43
- "@sdods/server": "0.11.3"
43
+ "@sdods/server": "0.12.0"
44
44
  },
45
45
  "homepage": "https://sdods.com",
46
46
  "bugs": {
@@ -32,13 +32,19 @@ an SDODS source checkout use `bun run sdods`.
32
32
  ```
33
33
  2. Run the requested slice. Typical combinations:
34
34
  ```bash
35
- sdods run -p <project> -e <env> -l api # API only, no browser
35
+ sdods run -p <project> -e <env> -l api # API only (still launches chromium: shared fixtures)
36
36
  sdods run -p <project> -e <env> -l ui -b chromium -t @smoke # quick UI smoke
37
37
  sdods run -p <project> -e <env> -t @regression --project-matrix # every browser in the yaml
38
38
  sdods run -p <project> -e <env> -t "@user:admin" # scenarios that lease admin users
39
39
  sdods run -p <project> -e <env> -l hybrid -w 2 # API→UI chaining
40
40
  sdods run -p <project> -e <env> --har-replay --strict -t @smoke # offline, CI-style
41
+ sdods run -p <project> -e <env> -b firefox --install-browsers # download missing browsers first
41
42
  ```
43
+ A run whose browser is missing or half-downloaded stops before generating specs with the
44
+ `sdods browsers install -b <engine>` command; `--install-browsers` (or
45
+ `SDODS_AUTO_INSTALL_BROWSERS=1`, which the desktop app sets) downloads it instead. Never answer
46
+ Playwright's "run npx playwright install" by running that from another folder: it installs a
47
+ different revision into a cache the runner does not read.
42
48
  3. Read results:
43
49
  ```bash
44
50
  sdods report --last # summary: totals, failed, flaky, report paths