@orkestrel/scaffold 0.0.16 → 0.0.17

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.
@@ -30,33 +30,33 @@ descriptions never enter agent instruction context.
30
30
  | Package | Version |
31
31
  | --------------------- | ------- |
32
32
  | @orkestrel/abort | 0.0.4 |
33
- | @orkestrel/agent | 0.0.11 |
33
+ | @orkestrel/agent | 0.0.13 |
34
34
  | @orkestrel/browser | 0.0.8 |
35
35
  | @orkestrel/budget | 0.0.4 |
36
36
  | @orkestrel/console | 0.0.4 |
37
37
  | @orkestrel/contract | 0.0.9 |
38
38
  | @orkestrel/csv | 0.0.2 |
39
- | @orkestrel/database | 0.0.6 |
39
+ | @orkestrel/database | 0.0.7 |
40
40
  | @orkestrel/emitter | 0.0.5 |
41
41
  | @orkestrel/guide | 0.0.8 |
42
42
  | @orkestrel/html | 0.0.1 |
43
43
  | @orkestrel/indexeddb | 0.0.6 |
44
44
  | @orkestrel/interpret | 0.0.7 |
45
45
  | @orkestrel/markdown | 0.0.7 |
46
- | @orkestrel/mcp | 0.0.7 |
46
+ | @orkestrel/mcp | 0.0.9 |
47
47
  | @orkestrel/middleware | 0.0.6 |
48
48
  | @orkestrel/msg | 0.0.5 |
49
49
  | @orkestrel/ndjson | 0.0.5 |
50
- | @orkestrel/ollama | 0.0.7 |
51
- | @orkestrel/pool | 0.0.4 |
50
+ | @orkestrel/ollama | 0.0.8 |
51
+ | @orkestrel/pool | 0.0.6 |
52
52
  | @orkestrel/program | 0.0.6 |
53
53
  | @orkestrel/qualifier | 0.0.7 |
54
- | @orkestrel/queue | 0.0.4 |
54
+ | @orkestrel/queue | 0.0.7 |
55
55
  | @orkestrel/rater | 0.0.8 |
56
56
  | @orkestrel/reason | 0.0.4 |
57
- | @orkestrel/relation | 0.0.6 |
58
- | @orkestrel/router | 0.0.6 |
59
- | @orkestrel/scaffold | 0.0.13 |
57
+ | @orkestrel/relation | 0.0.7 |
58
+ | @orkestrel/router | 0.0.7 |
59
+ | @orkestrel/scaffold | 0.0.16 |
60
60
  | @orkestrel/sea | 0.0.4 |
61
61
  | @orkestrel/server | 0.0.7 |
62
62
  | @orkestrel/sqlite | 0.0.6 |
@@ -64,12 +64,12 @@ descriptions never enter agent instruction context.
64
64
  | @orkestrel/template | 0.0.2 |
65
65
  | @orkestrel/terminal | 0.0.5 |
66
66
  | @orkestrel/timeout | 0.0.4 |
67
- | @orkestrel/tool | 0.0.7 |
68
- | @orkestrel/toolbox | 0.0.1 |
67
+ | @orkestrel/tool | 0.0.9 |
68
+ | @orkestrel/toolbox | 0.0.2 |
69
69
  | @orkestrel/websocket | 0.0.7 |
70
- | @orkestrel/worker | 0.0.4 |
71
- | @orkestrel/workflow | 0.0.7 |
72
- | @orkestrel/workspace | 0.0.1 |
70
+ | @orkestrel/worker | 0.0.5 |
71
+ | @orkestrel/workflow | 0.0.9 |
72
+ | @orkestrel/workspace | 0.0.2 |
73
73
 
74
74
  <!-- catalog:end -->
75
75
 
@@ -290,7 +290,8 @@ the accumulated `questions`, one `CompileRecord` per stage, any `CompileFailure`
290
290
  `GuideSync`, `VersionSync`, and `SyncReport` are the freshness shapes. `GuideSync` carries the
291
291
  fetched `content`, its `freshness`, an optional `note` explaining a non-clean outcome, and an
292
292
  optional `baseline` — the SHA-256 of the observed local mirror, or the literal `absent`, present
293
- only on target-aware pulls. `VersionSync` compares a declared `range` to the registry `latest`.
293
+ only on target-aware synchronization. `VersionSync` compares a declared `range` to the registry
294
+ `latest`.
294
295
  `SyncReport` is `clean` only when nothing drifted and nothing failed. `CatalogEntry` is one fleet
295
296
  package row; its `description` is the flattened text of that package's own guide's first
296
297
  blockquote, and the empty string when that guide is missing, unreadable, or carries no blockquote.
@@ -1014,7 +1015,7 @@ error.
1014
1015
 
1015
1016
  `packageShortName` strips the canonical scope, `guideStub` renders the pointer written when a
1016
1017
  dependency guide is not vendored yet, `readGuideReferences` reads a target's existing local mirrors
1017
- so a pull's verdicts are target-relative, and `syncReportOf` assembles one report from already
1018
+ for package names so synchronization verdicts are target-relative, and `syncReportOf` assembles one report from already
1018
1019
  ordered guide and version outcomes.
1019
1020
 
1020
1021
  ### Compilers — core
@@ -1110,7 +1111,7 @@ along the three `ViteMachinery` axes:
1110
1111
  | Machinery | Emitted when |
1111
1112
  | ------------------------------------------------------------------------ | ------------------------------------ |
1112
1113
  | Shared CSS analysis (`ENVIRONMENT_CSS`, `preprocessCSS`, `isCSSRequest`) | a `src` or `app` browser environment |
1113
- | Playwright provider and `resolveChromium` | a `src` or `app` browser environment |
1114
+ | Playwright provider and managed/system browser discovery | a `src` or `app` browser environment |
1114
1115
  | Vue plugin, HTML boundary, browser development server | an `app` browser environment |
1115
1116
  | Output containment (`outputBoundary`, `enforceOutputPath`) | anything the workspace builds |
1116
1117
 
@@ -1303,6 +1304,7 @@ The interface also exposes the readonly `emitter`.
1303
1304
  | `versions` | `Promise<readonly VersionSync[]>` |
1304
1305
  | `catalog` | `Promise<readonly CatalogEntry[]>` |
1305
1306
  | `pull` | `Promise<SyncReport>` |
1307
+ | `mirror` | `Promise<SyncReport>` |
1306
1308
  | `write` | `Promise<readonly string[]>` |
1307
1309
  | `destroy` | `void` |
1308
1310
 
@@ -1315,7 +1317,9 @@ latest. `catalog()` enumerates the fleet from the registry's exact organization
1315
1317
  unreachable or malformed list is always a coded failure, since without it there is no catalog — then
1316
1318
  degrades gracefully per package. `pull(target, dependencies?)` builds the reference map from the
1317
1319
  target's own mirrors, so its verdicts are target-relative, and rejects a selection the target does
1318
- not declare. `write(report, target)` commits only the `behind` guides. `destroy()` aborts every
1320
+ not declare. `mirror(target)` reuses the exact organization enumeration without catalog's
1321
+ per-package packument reads, sorts the names, excludes the target's own manifest name, and builds a
1322
+ guide-only report with no versions. `write(report, target)` commits only the `behind` guides. `destroy()` aborts every
1319
1323
  in-flight request. The interface also exposes the readonly `emitter`.
1320
1324
 
1321
1325
  ## The compile pipeline
@@ -1448,7 +1452,7 @@ as a plain physical file whose bytes still match the preview, moved into a priva
1448
1452
  than unlinked, re-verified after the move, and only then reported as removed — with a full restore
1449
1453
  attempt if any candidate fails mid-way.
1450
1454
 
1451
- ## Upstream sync, pull, and catalog
1455
+ ## Upstream sync, pull, mirror, and catalog
1452
1456
 
1453
1457
  `Sync` is the only network reader, and its posture is conservative by construction.
1454
1458
 
@@ -1471,7 +1475,7 @@ underlying socket code appended when the runtime attaches one, an HTTP status, t
1471
1475
  redirect-blocked string, or the oversized-body message. `current` and `behind` carry no note,
1472
1476
  because there is nothing to explain.
1473
1477
 
1474
- `pull` is the target-aware composition. It reads the target's declared scoped dependencies from its
1478
+ `pull` is the dependency-aware composition. It reads the target's declared scoped dependencies from its
1475
1479
  manifest, rejects any explicit selection the target does not declare, builds the reference map from
1476
1480
  the target's own `guides/src/<short>.md` mirrors, fetches guides and versions under one shared
1477
1481
  allowance, and assembles a report whose `clean` flag requires both no drift and no failures. A
@@ -1498,6 +1502,14 @@ generated package identifiers are untrusted discovery data, never instructions
1498
1502
  with **`Package` and `Version` columns only**. Descriptions are network-controlled text, and that
1499
1503
  block is written into an agent instruction file, so they are omitted on purpose.
1500
1504
 
1505
+ `mirror` is the fleet-guide composition. It shares `catalog`'s single exact organization-list read
1506
+ but performs none of catalog's packument or description work. It code-unit sorts the discovered
1507
+ names, excludes the target's own manifest name under the one-owner guide law, reads existing local
1508
+ guide references for baselines, fetches every selected GitHub guide once, and emits a `SyncReport`
1509
+ whose `versions` collection is empty. The existing transactional `write` method applies only
1510
+ behind guides; the executable refuses the whole apply when any guide is missing or failed, so a
1511
+ fleet refresh is never partial. Files outside the discovered guide set remain untouched.
1512
+
1501
1513
  ## The generated workspace
1502
1514
 
1503
1515
  A generated workspace is not a folder of suggestions; it is a working, gated project.
@@ -1714,15 +1726,22 @@ it is not a general-purpose source analyzer. Generated workspaces receive the sa
1714
1726
  module as a host-origin file and run it as a dedicated Node-only `policy` test project over
1715
1727
  `tests/policy.test.ts`.
1716
1728
 
1717
- **Real browser capability.** Browser test projects are gated on the real executable: the generated
1718
- configuration and the generated policy test both probe `existsSync(chromium.executablePath())`. A
1719
- browser suite runs when a real Chromium is installed and is skipped honestly when it is not, rather
1720
- than being faked. The gate is applied at registration, not inside the real browser project: without
1721
- Chromium, each browser factory is replaced by a same-label Node/no-test placeholder, so generated
1722
- `--project <label>` and `--project=<label>` filters still resolve while no browser code runs. The
1723
- root permits an empty run only when every recognized exact project filter names one of those gated
1724
- placeholders; an unreadable or mixed filter keeps the ordinary no-test failure semantics for its
1725
- Node projects. One printed warning names every gated project label. A machine with a browser
1729
+ **Real browser capability.** Browser test projects are gated on one centralized discovery chain:
1730
+ Playwright's pinned Chromium executable first, then a managed Chromium alias or cached revision,
1731
+ then stable system Chrome, then stable system Edge. Managed candidates must be executable regular
1732
+ files. System channels are selected only when their executable exists at Playwright's standard
1733
+ Linux, macOS, or Windows installation location; custom installations are not guessed. The generated
1734
+ policy test consumes the same discovery helpers and accepts either an executable managed path or the
1735
+ stable `chrome` / `msedge` channel, so it does not maintain a second heuristic.
1736
+
1737
+ A browser suite runs when any one of those real browser capabilities is available and is skipped
1738
+ honestly when none is, rather than being faked. The gate is applied at registration, not inside the
1739
+ real browser project: without a browser, each browser factory is replaced by a same-label
1740
+ Node/no-test placeholder, so generated `--project <label>` and `--project=<label>` filters still
1741
+ resolve while no browser code runs. The root permits an empty run only when every recognized exact
1742
+ project filter names one of those gated placeholders; an unreadable or mixed filter keeps the
1743
+ ordinary no-test failure semantics for its Node projects. One printed warning names every gated
1744
+ project label and says no Playwright Chromium, Chrome, or Edge was found. A machine with a browser
1726
1745
  registers and runs the real browser suites unchanged; a machine without one runs the remaining
1727
1746
  projects and says so.
1728
1747
 
@@ -1764,12 +1783,13 @@ each Codex agent's declared `sandbox_mode` is its mechanical permission floor, w
1764
1783
  ## The `scaffold` executable
1765
1784
 
1766
1785
  The bin is a thin command-line shell over the two library faces. It exports nothing, so it carries
1767
- no module API of its own. Six verbs:
1786
+ no module API of its own. Seven verbs:
1768
1787
 
1769
1788
  | Verb | Purpose |
1770
1789
  | --------- | -------------------------------------------------------- |
1771
1790
  | `new` | scaffold a workspace into `./<name>` |
1772
1791
  | `pull` | refresh vendored guides and versions, report drift |
1792
+ | `mirror` | refresh every published Orkestrel package guide |
1773
1793
  | `audit` | whole-plan conformance report |
1774
1794
  | `repair` | restore host-owned files and optional generated canon |
1775
1795
  | `fleet` | audit or repair every workspace under the cwd's children |
@@ -1789,8 +1809,10 @@ extras so the workspace stays audit-clean.
1789
1809
  repeatable and points at a local template or catalog source instead of the bundled one.
1790
1810
  On `pull`, `--deps x,y` limits refresh to those declared Orkestrel dependencies; without it, every
1791
1811
  declared dependency mirror is considered.
1812
+ `mirror` accepts no dependency selection: its exact npm organization discovery is the operation's
1813
+ scope, and it fetches guides without registry version or packument requests.
1792
1814
  `--groups a,b` scopes an audit to artifact groups. `--live` adds an upstream freshness check to an
1793
- audit. `--strict` makes a pull throw on a network fault. `--offline` restricts a catalog to local
1815
+ audit. `--strict` makes a pull or mirror throw on a network fault. `--offline` restricts a catalog to local
1794
1816
  sources. `--prune` opts a repair or fleet run into deleting unexpected files under the three prune
1795
1817
  directories. `--generated` opts a repair or fleet run into restoring generated canon except
1796
1818
  `package.json`; on `audit`, it is inherited if the interactive repair hand-off is accepted.
@@ -2260,7 +2282,7 @@ isBehind(rangeToFreshness('^0.0.7', '0.0.9')) // true
2260
2282
 
2261
2283
  packageShortName('@orkestrel/contract') // 'contract'
2262
2284
  guideStub('guides/src/contract.md') // the local pointer content
2263
- readGuideReferences('./packages/router', [{ name: '@orkestrel/contract', range: '^0.0.7' }])
2285
+ readGuideReferences('./packages/router', ['@orkestrel/contract'])
2264
2286
  syncReportOf('./packages/router', [], []) // { clean: true, failed: 0, … }
2265
2287
  ```
2266
2288
 
@@ -2325,9 +2347,24 @@ await sync.guides(deps)
2325
2347
  await sync.versions(deps)
2326
2348
  await sync.catalog()
2327
2349
 
2350
+ const mirror = await sync.mirror('.')
2351
+ if (mirror.failed === 0) await sync.write(mirror, '.')
2352
+
2328
2353
  sync.destroy()
2329
2354
  ```
2330
2355
 
2356
+ Refresh the entire published guide mirror from an installed package:
2357
+
2358
+ ```sh
2359
+ npx scaffold mirror --apply --yes
2360
+ ```
2361
+
2362
+ Or from this checkout after building:
2363
+
2364
+ ```sh
2365
+ node ./dist/bin/scaffold.js mirror --apply --yes
2366
+ ```
2367
+
2331
2368
  ### Fleet discovery, prune scanning, and the local catalog
2332
2369
 
2333
2370
  ```ts
@@ -227,7 +227,7 @@ var DEFAULT_VERSION = "0.0.1";
227
227
  /** The `engines.node` range the `blueprint` builder fills. */
228
228
  var DEFAULT_ENGINES = `>=${MINIMUM_NODE_VERSION}`;
229
229
  /** The devDependency range generated packages pin `@orkestrel/scaffold` at. */
230
- var SCAFFOLD_RANGE = "^0.0.16";
230
+ var SCAFFOLD_RANGE = "^0.0.17";
231
231
  /** Tooling versions shared by scaffold and every generated workspace. */
232
232
  var BASE_DEV_DEPENDENCIES = Object.freeze({
233
233
  "@microsoft/api-extractor": "^7.58.12",
@@ -5873,7 +5873,7 @@ ${renderedProjects}
5873
5873
  ${fitsPrintWidth(inlineRegistrations) ? inlineRegistrations : ` [
5874
5874
  ${renderedRegistrations.map((registration) => ` ${registration},`).join("\n")}
5875
5875
  ],`}
5876
- chromiumPath !== undefined,
5876
+ browserOptions !== undefined,
5877
5877
  process.argv,
5878
5878
  ),`;
5879
5879
  }
@@ -5897,6 +5897,8 @@ ${renderedRegistrations.map((registration) => ` ${registration},`).join("\n")}
5897
5897
  */
5898
5898
  function viteHeader(machinery) {
5899
5899
  const { browser: needsBrowser, vue: needsVue, output: needsOutput } = machinery;
5900
+ const playwrightTypeImports = needsBrowser ? `import type { PlaywrightProviderOptions } from '@vitest/browser-playwright'
5901
+ ` : "";
5900
5902
  const playwrightImports = needsBrowser ? `import { playwright } from '@vitest/browser-playwright'
5901
5903
  import { chromium } from 'playwright'
5902
5904
  ` : "";
@@ -6207,7 +6209,9 @@ import { parse as parseVue } from 'vue/compiler-sfc'
6207
6209
  ? undefined
6208
6210
  : packageRootOf(packageName, physicalSource)
6209
6211
  if (packageRoot === undefined || !containedPath(packageRoot, physicalSource)) {
6210
- this.error('Resolved dependencies must remain inside their physical package root')
6212
+ return this.error(
6213
+ 'Resolved dependencies must remain inside their physical package root',
6214
+ )
6211
6215
  }
6212
6216
  trustedPackageRoots.add(packageRoot)
6213
6217
  }
@@ -6215,7 +6219,7 @@ import { parse as parseVue } from 'vue/compiler-sfc'
6215
6219
  }
6216
6220
  const resolvedSource = workspacePath(physicalSource)
6217
6221
  if (resolvedSource === undefined) {
6218
- this.error('Environment modules cannot import files outside the workspace')
6222
+ return this.error('Environment modules cannot import files outside the workspace')
6219
6223
  }
6220
6224
  const assetError = environmentPathError(owner, resolvedSource)
6221
6225
  if (assetError !== undefined) this.error(assetError)
@@ -7244,7 +7248,7 @@ ${needsVue ? ` configureServer(server) {
7244
7248
  }
7245
7249
  }
7246
7250
  `;
7247
- return `${viteTypeImports}
7251
+ return `${playwrightTypeImports}${viteTypeImports}
7248
7252
  ${needsBrowser ? `import { isCSSRequest, parseSync, preprocessCSS, transformWithOxc, Visitor } from 'vite'
7249
7253
  ` : `import { parseSync, transformWithOxc, Visitor } from 'vite'
7250
7254
  `}import { defineConfig, mergeConfig } from 'vitest/config'
@@ -7252,7 +7256,7 @@ import tsconfig from './tsconfig.json' with { type: 'json' }
7252
7256
  import { fileURLToPath, URL } from 'node:url'
7253
7257
  import { isBuiltin } from 'node:module'
7254
7258
  import {
7255
- closeSync,
7259
+ ${needsBrowser ? " accessSync,\n" : ""} closeSync,
7256
7260
  constants as FS_CONSTANTS,
7257
7261
  existsSync,
7258
7262
  fstatSync,
@@ -7261,7 +7265,15 @@ import {
7261
7265
  ${needsBrowser ? " readdirSync,\n" : ""} readSync,
7262
7266
  realpathSync,
7263
7267
  ${needsBrowser ? " statSync,\n" : ""}} from 'node:fs'
7264
- import { ${needsBrowser ? "basename, " : ""}dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
7268
+ ${needsBrowser ? `import {
7269
+ basename,
7270
+ join,
7271
+ dirname,
7272
+ isAbsolute,
7273
+ relative,
7274
+ resolve as resolvePath,
7275
+ sep,
7276
+ } from 'node:path'` : `import { dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'`}
7265
7277
  ${playwrightImports}${vueImports}${needsBrowser ? `\n/** Chromium executable layouts inside a \`chromium-<revision>\` browsers-directory entry, per platform. */
7266
7278
  ${EXPORT_KEYWORD} ${CONST_KEYWORD} CHROMIUM_LAYOUTS = Object.freeze([
7267
7279
  'chrome-linux/chrome',
@@ -7272,15 +7284,64 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} CHROMIUM_LAYOUTS = Object.freeze([
7272
7284
  'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium',
7273
7285
  ])
7274
7286
 
7287
+ /** Stable Playwright Chromium channels and their standard executable layouts. */
7288
+ ${EXPORT_KEYWORD} ${CONST_KEYWORD} SYSTEM_BROWSER_CHANNELS = Object.freeze([
7289
+ Object.freeze({
7290
+ channel: 'chrome',
7291
+ layouts: Object.freeze({
7292
+ linux: '/opt/google/chrome/chrome',
7293
+ darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
7294
+ win32: Object.freeze(['Google', 'Chrome', 'Application', 'chrome.exe']),
7295
+ }),
7296
+ }),
7297
+ Object.freeze({
7298
+ channel: 'msedge',
7299
+ layouts: Object.freeze({
7300
+ linux: '/opt/microsoft/msedge/msedge',
7301
+ darwin: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
7302
+ win32: Object.freeze(['Microsoft', 'Edge', 'Application', 'msedge.exe']),
7303
+ }),
7304
+ }),
7305
+ ])
7306
+
7307
+ /**
7308
+ * Determine whether a path identifies an executable regular file.
7309
+ *
7310
+ * @param path - The filesystem path to inspect.
7311
+ * @returns Whether the path is a regular file with execute access.
7312
+ *
7313
+ * @example
7314
+ * \`\`\`ts
7315
+ * isBrowserExecutable('/opt/google/chrome/chrome')
7316
+ * \`\`\`
7317
+ */
7318
+ ${EXPORT_KEYWORD} function isBrowserExecutable(path: string): boolean {
7319
+ try {
7320
+ if (!statSync(path).isFile()) return false
7321
+ accessSync(path, FS_CONSTANTS.X_OK)
7322
+ return true
7323
+ } catch {
7324
+ return false
7325
+ }
7326
+ }
7327
+
7275
7328
  /**
7276
- * Resolve a launchable Chromium executable: the pinned revision when installed,
7329
+ * Resolve a launchable Playwright-managed Chromium executable: the pinned revision when installed,
7277
7330
  * otherwise a \`chromium\` / \`chromium.exe\` alias or any other \`chromium-*\`
7278
7331
  * revision under the same Playwright browsers directory. A pinned-revision miss
7279
7332
  * is not Chromium absence — managed containers ship one usable build (often
7280
7333
  * behind a revision-agnostic alias) for many Playwright versions.
7334
+ *
7335
+ * @param pinned - The executable path for Playwright's pinned Chromium revision.
7336
+ * @returns The managed executable path, or \`undefined\` when none is executable.
7337
+ *
7338
+ * @example
7339
+ * \`\`\`ts
7340
+ * resolveManagedBrowser(chromium.executablePath())
7341
+ * \`\`\`
7281
7342
  */
7282
- ${EXPORT_KEYWORD} function resolveChromium(pinned: string): string | undefined {
7283
- if (existsSync(pinned)) return pinned
7343
+ ${EXPORT_KEYWORD} function resolveManagedBrowser(pinned: string): string | undefined {
7344
+ if (isBrowserExecutable(pinned)) return pinned
7284
7345
  let revisionRoot = dirname(pinned)
7285
7346
  for (;;) {
7286
7347
  if (/^chromium-\\d+$/.test(basename(revisionRoot))) break
@@ -7291,7 +7352,7 @@ ${EXPORT_KEYWORD} function resolveChromium(pinned: string): string | undefined {
7291
7352
  const browsersRoot = dirname(revisionRoot)
7292
7353
  for (const alias of ['chromium', 'chromium.exe']) {
7293
7354
  const candidate = resolvePath(browsersRoot, alias)
7294
- if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
7355
+ if (isBrowserExecutable(candidate)) return candidate
7295
7356
  }
7296
7357
  let entries: readonly string[]
7297
7358
  try {
@@ -7305,18 +7366,89 @@ ${EXPORT_KEYWORD} function resolveChromium(pinned: string): string | undefined {
7305
7366
  for (const revision of revisions) {
7306
7367
  for (const layout of CHROMIUM_LAYOUTS) {
7307
7368
  const candidate = resolvePath(browsersRoot, revision, layout)
7308
- if (existsSync(candidate)) return candidate
7369
+ if (isBrowserExecutable(candidate)) return candidate
7370
+ }
7371
+ }
7372
+ return undefined
7373
+ }
7374
+
7375
+ /**
7376
+ * Resolve the first installed stable system Chromium channel.
7377
+ *
7378
+ * @param platform - The Node platform whose standard layouts should be probed.
7379
+ * @param environment - The process environment supplying Windows installation roots.
7380
+ * @returns \`chrome\`, then \`msedge\`, or \`undefined\` when neither is executable.
7381
+ *
7382
+ * @example
7383
+ * \`\`\`ts
7384
+ * resolveSystemBrowser(process.platform, process.env)
7385
+ * \`\`\`
7386
+ */
7387
+ ${EXPORT_KEYWORD} function resolveSystemBrowser(
7388
+ platform: NodeJS.Platform,
7389
+ environment: NodeJS.ProcessEnv,
7390
+ ): string | undefined {
7391
+ if (platform !== 'linux' && platform !== 'darwin' && platform !== 'win32') return undefined
7392
+ const roots = new Set<string>()
7393
+ if (platform === 'win32') {
7394
+ for (const root of [
7395
+ environment.LOCALAPPDATA,
7396
+ environment.PROGRAMFILES,
7397
+ environment['PROGRAMFILES(X86)'],
7398
+ ]) {
7399
+ if (root !== undefined && root.length > 0) roots.add(root)
7400
+ }
7401
+ const homeDrive = environment.HOMEDRIVE
7402
+ if (homeDrive !== undefined && homeDrive.length > 0) {
7403
+ roots.add(join(homeDrive, 'Program Files'))
7404
+ roots.add(join(homeDrive, 'Program Files (x86)'))
7405
+ }
7406
+ }
7407
+ for (const browser of SYSTEM_BROWSER_CHANNELS) {
7408
+ if (platform === 'win32') {
7409
+ for (const root of roots) {
7410
+ if (isBrowserExecutable(join(root, ...browser.layouts.win32))) return browser.channel
7411
+ }
7412
+ continue
7309
7413
  }
7414
+ if (isBrowserExecutable(browser.layouts[platform])) return browser.channel
7310
7415
  }
7311
7416
  return undefined
7312
7417
  }
7313
7418
 
7314
- ${CONST_KEYWORD} chromiumPinned = chromium.executablePath()
7315
- ${CONST_KEYWORD} chromiumPath = resolveChromium(chromiumPinned)
7316
- ${CONST_KEYWORD} chromiumOptions =
7317
- chromiumPath === undefined || chromiumPath === chromiumPinned
7318
- ? {}
7319
- : { launchOptions: { executablePath: chromiumPath } }
7419
+ /**
7420
+ * Resolve launch options for a managed Chromium or stable system browser.
7421
+ *
7422
+ * @param pinned - The executable path for Playwright's pinned Chromium revision.
7423
+ * @param platform - The Node platform whose standard system layouts should be probed.
7424
+ * @param environment - The process environment supplying Windows installation roots.
7425
+ * @returns Provider options for managed Chromium, Chrome, or Edge, or \`undefined\`.
7426
+ *
7427
+ * @remarks
7428
+ * An installed pinned revision returns an empty object so Playwright retains
7429
+ * its default launch semantics. A different managed executable is selected by
7430
+ * path; a system browser is selected by its stable Playwright channel.
7431
+ *
7432
+ * @example
7433
+ * \`\`\`ts
7434
+ * resolveBrowser(chromium.executablePath(), process.platform, process.env)
7435
+ * \`\`\`
7436
+ */
7437
+ ${EXPORT_KEYWORD} function resolveBrowser(
7438
+ pinned: string,
7439
+ platform: NodeJS.Platform,
7440
+ environment: NodeJS.ProcessEnv,
7441
+ ): PlaywrightProviderOptions | undefined {
7442
+ const managed = resolveManagedBrowser(pinned)
7443
+ if (managed !== undefined) {
7444
+ return managed === pinned ? {} : { launchOptions: { executablePath: managed } }
7445
+ }
7446
+ const channel = resolveSystemBrowser(platform, environment)
7447
+ return channel === undefined ? undefined : { launchOptions: { channel } }
7448
+ }
7449
+
7450
+ ${CONST_KEYWORD} browserPinned = chromium.executablePath()
7451
+ ${CONST_KEYWORD} browserOptions = resolveBrowser(browserPinned, process.platform, process.env)
7320
7452
  ` : ""}
7321
7453
  ${EXPORT_KEYWORD} function resolveWorkspacePath(relativePath: string): string {
7322
7454
  return fileURLToPath(new URL(relativePath, import.meta.url))
@@ -7365,7 +7497,9 @@ ${needsBrowser ? `${EXPORT_KEYWORD} function gateBrowserProjects(
7365
7497
  projects.push(registration.project())
7366
7498
  }
7367
7499
  if (gated.length === 0) return { projects }
7368
- console.warn(\`browser projects skipped: Chromium absent (\${gated.join(', ')})\`)
7500
+ console.warn(
7501
+ \`browser projects skipped: no Playwright Chromium, Chrome, or Edge found (\${gated.join(', ')})\`,
7502
+ )
7369
7503
  const filters: string[] = []
7370
7504
  let readable = true
7371
7505
  for (let index = 0; index < argv.length; index += 1) {
@@ -7656,7 +7790,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
7656
7790
  }),
7657
7791
  browser: {
7658
7792
  enabled: true,
7659
- provider: playwright(chromiumOptions),
7793
+ provider: playwright(browserOptions),
7660
7794
  instances: [{ browser: 'chromium', headless: true }],
7661
7795
  },
7662
7796
  fileParallelism: false,
@@ -7788,7 +7922,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
7788
7922
  }),
7789
7923
  browser: {
7790
7924
  enabled: true,
7791
- provider: playwright(chromiumOptions),
7925
+ provider: playwright(browserOptions),
7792
7926
  instances: [{ browser: 'chromium', headless: true }],
7793
7927
  },
7794
7928
  fileParallelism: false,
@@ -7953,7 +8087,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
7953
8087
  }),
7954
8088
  browser: {
7955
8089
  enabled: true,
7956
- provider: playwright(chromiumOptions),
8090
+ provider: playwright(browserOptions),
7957
8091
  instances: [{ browser: 'chromium', headless: true }],
7958
8092
  },
7959
8093
  fileParallelism: false,
@@ -8082,7 +8216,7 @@ ${EXPORT_KEYWORD} function appBrowser(...config: readonly never[]): UserConfig {
8082
8216
  },
8083
8217
  browser: {
8084
8218
  enabled: true,
8085
- provider: playwright(chromiumOptions),
8219
+ provider: playwright(browserOptions),
8086
8220
  instances: [{ browser: 'chromium', headless: true }],
8087
8221
  },
8088
8222
  fileParallelism: false,
@@ -8649,8 +8783,7 @@ function paritySpecifiers(spec) {
8649
8783
  function testArtifacts(spec, pascal) {
8650
8784
  const hasBrowser = spec.src.includes("browser") || spec.app.includes("browser");
8651
8785
  const hasVue = spec.app.includes("browser");
8652
- const browserPolicySpecifier = hasBrowser ? ", accessSync, constants as FS_CONSTANTS, statSync" : "";
8653
- const browserPolicyImport = hasBrowser ? "\nimport { chromium } from 'playwright'\nimport { resolveChromium } from '../vite.config.js'" : "";
8786
+ const browserPolicyImport = hasBrowser ? "\nimport { chromium } from 'playwright'\nimport { isBrowserExecutable, resolveBrowser, SYSTEM_BROWSER_CHANNELS } from '../vite.config.js'" : "";
8654
8787
  const vuePolicyImport = hasVue ? "\nimport { parse as parseVue } from 'vue/compiler-sfc'" : "";
8655
8788
  const workspacePolicyAssertion = hasVue ? `expect(
8656
8789
  inspectCodingWorkspace(process.cwd(), (path, content) => {
@@ -8663,15 +8796,20 @@ function testArtifacts(spec, pascal) {
8663
8796
  ).toEqual([])` : "expect(inspectCodingWorkspace(process.cwd())).toEqual([])";
8664
8797
  const browserPolicyTest = hasBrowser ? `
8665
8798
 
8666
- it('resolves Chromium only to a real executable file', () => {
8667
- const chromiumPath = resolveChromium(chromium.executablePath())
8668
- if (chromiumPath === undefined) return
8669
-
8670
- expect(statSync(chromiumPath).isFile()).toBe(true)
8671
- expect(() => accessSync(chromiumPath, FS_CONSTANTS.X_OK)).not.toThrow()
8799
+ it('resolves only a real managed executable or stable system browser channel', () => {
8800
+ const options = resolveBrowser(chromium.executablePath(), process.platform, process.env)
8801
+ let valid = options === undefined
8802
+ if (options !== undefined) {
8803
+ const channel = options.launchOptions?.channel
8804
+ valid =
8805
+ channel === undefined
8806
+ ? isBrowserExecutable(options.launchOptions?.executablePath ?? chromium.executablePath())
8807
+ : SYSTEM_BROWSER_CHANNELS.some((browser) => browser.channel === channel)
8808
+ }
8809
+ expect(valid).toBe(true)
8672
8810
  })` : "";
8673
8811
  const artifacts = [fillArtifact("tests/setup.ts", "tests", "setup", {}), fillArtifact("tests/policy.test.ts", "tests", "policyTest", {
8674
- browserPolicySpecifier,
8812
+ browserPolicySpecifier: "",
8675
8813
  browserPolicyImport,
8676
8814
  browserPolicyTest,
8677
8815
  vuePolicyImport,