@ultimat3/cli 24.0.0 → 25.1.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.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
16
16
  import type { Finding } from './output';
17
+ import { pathSegments } from './path-segments';
18
+ import { toPosix } from './posix-path';
17
19
  import {
18
20
  entryFilesOf,
19
21
  FIXTURE_SUFFIX,
@@ -23,6 +25,8 @@ import {
23
25
  } from './publish-closure';
24
26
  import { isReExportManifest } from './reexport-manifest';
25
27
  import { eachSourceFile, isGenerated } from './source-files';
28
+ import type { ShippedPackage } from './test-only-shipped';
29
+ import { checkTestOnlyShipped, entryPatternsOf } from './test-only-shipped';
26
30
  import { checkRootReferences } from './tsconfig-references';
27
31
 
28
32
  export const LINE_CEILING = 500;
@@ -53,15 +57,41 @@ export const countLines = (text: string): number =>
53
57
  * statement of logic in it is not, and only reading tells them apart.
54
58
  */
55
59
  export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
60
+ return (await fileSizeReport(root)).findings;
61
+ }
62
+
63
+ /**
64
+ * Where a split gets planned rather than forced. Seven files once sat at exactly 500 lines,
65
+ * trimmed to the ceiling instead of split: a band that lists a file at 450 makes the split a
66
+ * decision taken with room to spare. Never a finding — a warning that fails the step is a ceiling.
67
+ */
68
+ export const WARN_BAND = 450;
69
+
70
+ /** A file in the band: at or over `WARN_BAND`, at or under the ceiling. */
71
+ export interface FileSizeWarning {
72
+ readonly path: string;
73
+ readonly lines: number;
74
+ readonly ceiling: number;
75
+ }
76
+
77
+ export interface FileSizeReport {
78
+ readonly findings: readonly Finding[];
79
+ readonly warnings: readonly FileSizeWarning[];
80
+ }
81
+
82
+ /** The ceiling's findings and the band's warnings, from one read of every file. */
83
+ export async function fileSizeReport(root: string): Promise<FileSizeReport> {
56
84
  const findings: Finding[] = [];
85
+ const warnings: FileSizeWarning[] = [];
57
86
  for await (const path of eachSourceFile(root)) {
58
87
  if (isGenerated(path)) continue;
59
88
  const source = await Bun.file(join(root, path)).text();
60
89
  const lines = countLines(source);
61
- if (lines <= LINE_CEILING || isReExportManifest(source)) continue;
62
- findings.push(tooLongFinding(path, lines));
90
+ if (lines < WARN_BAND || isReExportManifest(source)) continue;
91
+ if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
92
+ else warnings.push({ path, lines, ceiling: LINE_CEILING });
63
93
  }
64
- return findings;
94
+ return { findings, warnings };
65
95
  }
66
96
 
67
97
  export const missingFileFinding = (dir: string, file: string, scaffolder: boolean): Finding => ({
@@ -307,13 +337,20 @@ export function frameworkDepsOf(manifest: unknown): ManifestFacts['frameworkDeps
307
337
  });
308
338
  }
309
339
 
340
+ /**
341
+ * `packages/<dir>/package.json` → `<dir>`, from a glob answer in the host's separator. Split on `/`
342
+ * alone, Windows' `packages\db\package.json` named no directory, so an app's `package-shape` found
343
+ * no workspace and its floor reported `X_VERIFY_SUITE_VANISHED` on a scaffold fresh from `x new`.
344
+ */
345
+ export const workspaceDirOf = (globbed: string): string | undefined => pathSegments(globbed)[1];
346
+
310
347
  export async function workspacePackages(root: string): Promise<readonly string[]> {
311
348
  const dirs: string[] = [];
312
349
  for await (const path of new Bun.Glob('packages/*/package.json').scan({
313
350
  cwd: root,
314
351
  absolute: false,
315
352
  })) {
316
- const dir = path.split('/')[1];
353
+ const dir = workspaceDirOf(path);
317
354
  if (dir !== undefined) dirs.push(dir);
318
355
  }
319
356
  return dirs.sort();
@@ -345,7 +382,10 @@ async function fixtureReach(root: string, dir: string, manifest: unknown): Promi
345
382
  // consumer's, so no `*.test.ts` is read.
346
383
  const sources = new Map<string, string>();
347
384
  for await (const path of new Bun.Glob('src/**/*.{ts,tsx}').scan({ cwd: base, absolute: false })) {
348
- if (!/\.test\.tsx?$/.test(path)) sources.set(path, readFileSync(join(base, path), 'utf8'));
385
+ // Keyed POSIX: `entryFilesOf` and every import the walk resolves are, on every host.
386
+ if (!/\.test\.tsx?$/.test(path)) {
387
+ sources.set(toPosix(path), readFileSync(join(base, path), 'utf8'));
388
+ }
349
389
  }
350
390
  if (!mayReachFixture(sources, entries)) return [];
351
391
  return fixturesReachedFrom({ read: (path) => sources.get(path) }, entries).map((fixture) =>
@@ -361,6 +401,7 @@ export async function checkPackageShape(
361
401
  const scaffolder = existsSync(join(root, 'scripts', 'new-package.ts'));
362
402
  const findings: Finding[] = [];
363
403
  const facts: ManifestFacts[] = [];
404
+ const shipped: ShippedPackage[] = [];
364
405
  const allowlisted = new Set(ARTIFACT_ALLOWLIST);
365
406
  for (const dir of await workspacePackages(root)) {
366
407
  for (const file of PACKAGE_FILES) {
@@ -373,7 +414,7 @@ export async function checkPackageShape(
373
414
  cwd: join(root, 'packages', dir),
374
415
  absolute: false,
375
416
  })) {
376
- if (!allowlisted.has(join('packages', dir, path))) artifacts.push(path);
417
+ if (!allowlisted.has(toPosix(join('packages', dir, path)))) artifacts.push(path);
377
418
  }
378
419
  if (artifacts.length > 0) {
379
420
  findings.push(buildArtifactsFinding(dir, artifacts.length));
@@ -389,7 +430,10 @@ export async function checkPackageShape(
389
430
  findings.push(badVersionFinding(dir, version));
390
431
  continue;
391
432
  }
392
- if (record.private !== true) findings.push(...(await fixtureReach(root, dir, manifest)));
433
+ if (record.private !== true) {
434
+ findings.push(...(await fixtureReach(root, dir, manifest)));
435
+ shipped.push({ dir, entries: entryPatternsOf(manifest) });
436
+ }
393
437
  facts.push({
394
438
  dir,
395
439
  name: typeof record.name === 'string' ? record.name : `@ultimat3/${dir}`,
@@ -403,6 +447,8 @@ export async function checkPackageShape(
403
447
  ...findings,
404
448
  ...checkLockstep(facts, options.release),
405
449
  ...checkPublishShape(root, facts),
450
+ // The other way test code ships: a module only tests import, under a name `files` keeps.
451
+ ...(await checkTestOnlyShipped(root, shipped)),
406
452
  // Nothing enforced that a workspace joins the root build graph, and `scripts/new-package.ts`
407
453
  // never added one — so a package could ship, be imported, and be typechecked by nothing. It
408
454
  // rides on this step because it is this step's own question: what does a workspace owe the
@@ -4,7 +4,7 @@
4
4
  // for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
5
5
 
6
6
  import { join } from 'node:path';
7
- import { ERROR_DOCS_URL } from '@ultimat3/core';
7
+ import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
8
8
  import { scanAllImports } from './import-scan';
9
9
  import type { Finding } from './output';
10
10
  import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
@@ -148,10 +148,11 @@ export function packageOfSpecifier(specifier: string): string | undefined {
148
148
  * included, because a type edge still needs its manifest line. The transpiler is the parser, so a
149
149
  * generator's template literal (even one nested in a `${…}`, #493) is a string and never an import,
150
150
  * and `from<Row>('posts')` is a call. A file it cannot parse throws rather than reading as empty.
151
+ * The mask is what reads a type-position `typeof import('x')`, which the transpiler erases whole.
151
152
  */
152
153
  export function importedPackages(source: string, path = 'source.ts'): readonly string[] {
153
154
  const packages = new Set<string>();
154
- for (const specifier of scanAllImports({ path, source })) {
155
+ for (const specifier of scanAllImports({ path, source }, maskLiterals(source))) {
155
156
  const name = packageOfSpecifier(specifier);
156
157
  if (name !== undefined) packages.add(name);
157
158
  }
package/src/write-line.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { writeSync } from 'node:fs';
8
8
  // The one import beyond `node:fs`, and it costs nothing here: `create-ultimate` reaches this
9
9
  // module through `@ultimat3/cli`'s barrel, which has already evaluated core.
10
- import { stringField } from '@ultimat3/core';
10
+ import { backoffDelay, stringField } from '@ultimat3/core';
11
11
 
12
12
  /**
13
13
  * Write to stdout and be certain it arrived, even if the next statement exits the process.
@@ -22,22 +22,97 @@ import { stringField } from '@ultimat3/core';
22
22
  *
23
23
  * `EAGAIN` is "the pipe is full right now", not a failure. CI hands the process a NON-BLOCKING
24
24
  * stdout, where `writeSync` throws rather than blocking — so the loop that fixed the truncation
25
- * took the whole command down on a runner, emitting nothing at all. The reader drains in
26
- * microseconds; the retry is the correct response to "would block".
25
+ * took the whole command down on a runner, emitting nothing at all. A draining reader clears it in
26
+ * microseconds, so it is retried — but BOUNDED: a reader that never drains (a stopped `| less`, a
27
+ * wedged parent) turned the unbounded retry into a command that never exits, spinning a core.
28
+ * `EAGAIN_ATTEMPTS` consecutive stalls, each waiting longer (capped), then the line is dropped and
29
+ * counted (`droppedLineCount()`): one lost line beats a process that cannot finish. After a drop
30
+ * the fd is DEGRADED: each later line gets one attempt and no sleep, or a never-draining reader
31
+ * would cost ~1.5 s per line forever. Any byte arriving clears it.
27
32
  */
28
33
  function writeTo(fd: 1 | 2, line: string): void {
29
- const buffer = Buffer.from(`${line}\n`);
34
+ writeAll(fd, Buffer.from(`${line}\n`));
35
+ }
36
+
37
+ /** Consecutive stalls one write may absorb before its line is dropped. Progress resets it. */
38
+ export const EAGAIN_ATTEMPTS = 50;
39
+
40
+ /**
41
+ * Core's one curve, exponential and capped: 1, 2, 4 … 32 ms — under 1.5 s for a reader that never
42
+ * drains at all. No jitter: one process waiting on its own pipe has no herd to decorrelate.
43
+ */
44
+ const backoffMs = (stall: number): number => backoffDelay({ attempt: stall, base: 1, max: 32 });
45
+
46
+ /** `writeSync`'s shape, so a test can hand in a pipe that is full forever. */
47
+ export type WriteSync = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
48
+
49
+ let dropped = 0;
50
+
51
+ /**
52
+ * Per fd, because fd 1 and fd 2 are two readers. `degraded`: the last line was dropped, so the next
53
+ * gets one attempt. `torn`: it was dropped HALF-written, so the next line that arrives opens with
54
+ * the newline the torn one never got — otherwise the two glue into one unparseable line.
55
+ */
56
+ const pipes = new Map<number, { degraded: boolean; torn: boolean }>();
57
+
58
+ const pipeOf = (fd: number): { degraded: boolean; torn: boolean } => {
59
+ let pipe = pipes.get(fd);
60
+ if (pipe === undefined) {
61
+ pipe = { degraded: false, torn: false };
62
+ pipes.set(fd, pipe);
63
+ }
64
+ return pipe;
65
+ };
66
+
67
+ const NEWLINE = 0x0a;
68
+
69
+ /** Lines this process dropped to a reader that never drained. */
70
+ export const droppedLineCount = (): number => dropped;
71
+
72
+ /**
73
+ * Write every byte of `buffer` to `fd`, or drop it after `EAGAIN_ATTEMPTS` stalls in a row — one
74
+ * stall while the fd is degraded. Returns whether it all arrived. A write of zero bytes is a stall
75
+ * too, so no path spins.
76
+ */
77
+ export function writeAll(
78
+ fd: number,
79
+ buffer: Uint8Array,
80
+ write: WriteSync = writeSync,
81
+ sleep: (ms: number) => void = Bun.sleepSync,
82
+ ): boolean {
83
+ const pipe = pipeOf(fd);
84
+ const prefix = pipe.torn ? 1 : 0;
85
+ const bytes = prefix === 0 ? buffer : Buffer.concat([Uint8Array.of(NEWLINE), buffer]);
30
86
  let written = 0;
31
- while (written < buffer.length) {
87
+ let stalls = 0;
88
+ while (written < bytes.length) {
89
+ let progress = 0;
32
90
  try {
33
- written += writeSync(fd, buffer, written, buffer.length - written);
91
+ progress = write(fd, bytes, written, bytes.length - written);
34
92
  } catch (cause) {
35
93
  // `stringField`, never a cast plus a property read — the rule `metrics-endpoint.ts` states
36
94
  // and `caught-value-reads.test.ts` enforces. Here it is also the difference between
37
- // rethrowing and an infinite loop: a `code` that cannot be read must not read as `EAGAIN`.
95
+ // rethrowing and retrying: a `code` that cannot be read must not read as `EAGAIN`.
38
96
  if (stringField(cause, 'code') !== 'EAGAIN') throw cause;
39
97
  }
98
+ if (progress > 0) {
99
+ written += progress;
100
+ stalls = 0;
101
+ pipe.degraded = false; // The reader drained: it gets the full budget again.
102
+ continue;
103
+ }
104
+ stalls += 1;
105
+ if (stalls >= (pipe.degraded ? 1 : EAGAIN_ATTEMPTS)) {
106
+ dropped += 1;
107
+ pipe.degraded = true;
108
+ // Nothing written leaves a torn line torn; only the prefix written closes it cleanly.
109
+ if (written > 0) pipe.torn = written > prefix;
110
+ return false;
111
+ }
112
+ sleep(backoffMs(stalls));
40
113
  }
114
+ pipe.torn = false;
115
+ return true;
41
116
  }
42
117
 
43
118
  export function writeLine(line: string): void {
@@ -1,80 +0,0 @@
1
- // CSS selectors over an HTML string, on Bun's own `HTMLRewriter`: the `query()` the offline
2
- // `fakeShotDriver` answers from markup. The selector subset is lol-html's — type, `#id`, `.class`,
3
- // `[attr]`, `[attr=value]`, descendant and child combinators. No box and no hit-target on any
4
- // answer: this has no layout engine, and a fabricated box would make a covered button clickable.
5
- import type { ElementSnapshot } from './browser-launcher-port';
6
-
7
- /** Elements with no end tag: `onEndTag` never fires for them, so they close on open. */
8
- const VOID_TAGS = new Set([
9
- 'area',
10
- 'base',
11
- 'br',
12
- 'col',
13
- 'embed',
14
- 'hr',
15
- 'img',
16
- 'input',
17
- 'link',
18
- 'meta',
19
- 'source',
20
- 'track',
21
- 'wbr',
22
- ]);
23
-
24
- const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:;|$)/i;
25
-
26
- const visible = (tag: string, attrs: ReadonlyMap<string, string>): boolean =>
27
- !attrs.has('hidden') &&
28
- !(tag === 'input' && attrs.get('type')?.toLowerCase() === 'hidden') &&
29
- !HIDDEN_STYLE.test(attrs.get('style') ?? '') &&
30
- attrs.get('aria-hidden') !== 'true';
31
-
32
- interface Open {
33
- readonly tag: string;
34
- readonly attrs: Map<string, string>;
35
- readonly parts: string[];
36
- }
37
-
38
- const finish = (open: Open): ElementSnapshot => ({
39
- tag: open.tag,
40
- attrs: Object.fromEntries(open.attrs),
41
- text: open.parts.join('').replaceAll(/\s+/g, ' ').trim(),
42
- value: open.attrs.get('value') ?? '',
43
- visible: visible(open.tag, open.attrs),
44
- enabled: !open.attrs.has('disabled') && open.attrs.get('aria-disabled') !== 'true',
45
- });
46
-
47
- /** Every element matching `selector`, as the markup states it. A selector lol-html refuses throws. */
48
- export async function queryHtml(
49
- html: string,
50
- selector: string,
51
- ): Promise<readonly ElementSnapshot[]> {
52
- const done: ElementSnapshot[] = [];
53
- const open: Open[] = [];
54
- const rewriter = new HTMLRewriter().on(selector, {
55
- element(element): void {
56
- const tag = element.tagName.toLowerCase();
57
- const attrs = new Map<string, string>();
58
- for (const [name, value] of element.attributes) attrs.set(name.toLowerCase(), value);
59
- const record: Open = { tag, attrs, parts: [] };
60
- if (VOID_TAGS.has(tag)) {
61
- done.push(finish(record));
62
- return;
63
- }
64
- open.push(record);
65
- element.onEndTag(() => {
66
- const index = open.lastIndexOf(record);
67
- if (index !== -1) open.splice(index, 1);
68
- done.push(finish(record));
69
- });
70
- },
71
- text(chunk): void {
72
- // Every OPEN match: `div, span` matches both, and both contain the text.
73
- for (const record of open) record.parts.push(chunk.text);
74
- },
75
- });
76
- await rewriter.transform(new Response(html)).text();
77
- // Never closed is malformed markup; the element still matched, so it is still reported.
78
- for (const record of open) done.push(finish(record));
79
- return done;
80
- }
@@ -1,168 +0,0 @@
1
- // `fakeShotDriver`: a `ShotDriver` over recorded pages, so every `x shot` and `ui.*` test runs with
2
- // no Chrome. It parses markup and runs none of it — `query()` is answered from the HTML,
3
- // `evaluate()` from a recorded table, and a click follows `data-goto` or an `<a href>`. What it
4
- // cannot know it refuses by name rather than inventing: no layout box, and no accessibility tree.
5
- import { hostDecision, notImplemented } from '@ultimat3/core';
6
- import { CdpCallFailedError } from '@ultimat3/testing';
7
- import { queryHtml } from './browser-launcher-fake-html';
8
- import type {
9
- CaptureClip,
10
- ElementSnapshot,
11
- NetworkEntry,
12
- ShotColorScheme,
13
- ShotDriver,
14
- ShotPage,
15
- ShotSession,
16
- ShotSessionInit,
17
- } from './browser-launcher-port';
18
- import { awaitReady } from './cdp-shot-element';
19
- import { ShotHostRefusedError } from './cdp-shot-errors';
20
- import { parseKeyChord } from './cdp-shot-keys';
21
-
22
- export const FAKE_SHOT_DRIVER = 'fake';
23
-
24
- /** One recorded page: its url, its markup, and `expression -> JSON text` for `evaluate()`. */
25
- export interface FakeShotPage {
26
- readonly url: string;
27
- readonly html: string;
28
- readonly evaluate?: Readonly<Record<string, string>> | undefined;
29
- /** The document's HTTP status. Absent is 200 — the page was recorded because it exists. */
30
- readonly status?: number | undefined;
31
- }
32
-
33
- /** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
34
- export const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
35
-
36
- /**
37
- * Different deterministic bytes for every framing fact that was set — one per rectangle, one per
38
- * colour preference — so a caller that dropped the clip or the scheme fails a test here, which is
39
- * the only place a framing knob can be proved without a browser (issue #338).
40
- */
41
- export const framedPng = (clip: CaptureClip | undefined, scheme: ShotColorScheme): Uint8Array => {
42
- const notes =
43
- (clip === undefined
44
- ? ''
45
- : ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
46
- (scheme === 'no-preference' ? '' : ` scheme ${scheme}`);
47
- if (notes === '') return FAKE_PNG;
48
- const suffix = new TextEncoder().encode(notes);
49
- const out = new Uint8Array(FAKE_PNG.length + suffix.length);
50
- out.set(FAKE_PNG);
51
- out.set(suffix, FAKE_PNG.length);
52
- return out;
53
- };
54
-
55
- const normalise = (url: string): string => {
56
- try {
57
- return new URL(url).toString();
58
- } catch {
59
- return url;
60
- }
61
- };
62
-
63
- function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionInit): ShotPage {
64
- let current: FakeShotPage | undefined;
65
- let scheme: ShotColorScheme = 'no-preference';
66
- const network: NetworkEntry[] = [];
67
- const url = (): string => current?.url ?? 'about:blank';
68
-
69
- const navigate = (to: string): void => {
70
- const page = byUrl.get(normalise(to));
71
- if (page === undefined) {
72
- throw new CdpCallFailedError({
73
- method: `Page.navigate to ${to}`,
74
- detail: 'fakeShotDriver() has no page recorded at that url',
75
- });
76
- }
77
- current = page;
78
- network.push({
79
- method: 'GET',
80
- url: page.url,
81
- // Data the page answers with, never a bound — a recorded page exists, so absent is a 200.
82
- status: page.status === undefined ? 200 : page.status,
83
- resourceType: 'document',
84
- at: init.clock.now().getTime(),
85
- });
86
- };
87
-
88
- const query = (selector: string): Promise<readonly ElementSnapshot[]> =>
89
- queryHtml(current?.html ?? '', selector);
90
-
91
- const ready = (selector: string, options?: Parameters<ShotPage['waitFor']>[1]) =>
92
- awaitReady({
93
- selector,
94
- state: options?.state ?? 'actionable',
95
- timeoutMs: options?.timeout ?? init.timeoutMs,
96
- clock: init.clock,
97
- url,
98
- snapshot: async () => (await query(selector))[0],
99
- });
100
-
101
- return {
102
- url,
103
- async goto(to) {
104
- if (!hostDecision(to, init.rules.allowHosts).allowed) {
105
- throw new ShotHostRefusedError({ url: to, allowHosts: init.rules.allowHosts });
106
- }
107
- navigate(to);
108
- },
109
- waitFor: (selector, options) => ready(selector, options),
110
- async click(selector, options) {
111
- const target = await ready(selector, options);
112
- // What the browser would do with the click, as far as markup can say: follow the link.
113
- const next =
114
- target.attrs['data-goto'] ?? (target.tag === 'a' ? target.attrs['href'] : undefined);
115
- if (next !== undefined) navigate(new URL(next, url()).toString());
116
- },
117
- async type(selector, _text, options) {
118
- await ready(selector, options);
119
- },
120
- async focus(selector, options) {
121
- await ready(selector, options);
122
- },
123
- async press(chord) {
124
- parseKeyChord(chord);
125
- },
126
- async accessibility() {
127
- return notImplemented(
128
- 'accessibility() on fakeShotDriver()',
129
- 'assert on the markup with page.query(selector) — a role read off the HTML would hide the element a screen reader cannot name, which is the finding this read exists for',
130
- );
131
- },
132
- query,
133
- async evaluate(expression) {
134
- const table = current?.evaluate ?? {};
135
- const recorded = Object.hasOwn(table, expression) ? table[expression] : undefined;
136
- if (recorded === undefined) {
137
- throw new CdpCallFailedError({
138
- method: 'Runtime.evaluate',
139
- detail: 'fakeShotDriver() has no answer recorded for that expression on this page',
140
- });
141
- }
142
- return JSON.parse(recorded) as unknown;
143
- },
144
- async screenshot(options) {
145
- return framedPng(options?.clip, scheme);
146
- },
147
- async colorScheme(next) {
148
- scheme = next;
149
- },
150
- async prepare() {},
151
- console: () => [],
152
- pageErrors: () => [],
153
- pageErrorsDropped: () => 0,
154
- network: () => [...network],
155
- networkDropped: () => 0,
156
- };
157
- }
158
-
159
- /** A driver whose every session reads from `pages`. An unrecorded url is refused, never fetched. */
160
- export function fakeShotDriver(pages: readonly FakeShotPage[]): ShotDriver {
161
- const byUrl = new Map(pages.map((page) => [normalise(page.url), page]));
162
- return {
163
- name: FAKE_SHOT_DRIVER,
164
- async open(init): Promise<ShotSession> {
165
- return { page: fakePage(byUrl, init), close: async () => undefined };
166
- },
167
- };
168
- }