@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
@@ -1,40 +1,32 @@
1
- // The scaffold's answer to "who is this?", which it did not have.
1
+ // The scaffold's answer to "who is this?": a session cookie, resolved by `@ultimat3/auth` over the
2
+ // framework's own `x_users` / `x_sessions` tables, installed in EVERY environment.
2
3
  //
3
- // `hooks.authenticate` is the ONLY place an actor can come from, and nothing in a generated app
4
- // called `configureAuthenticator()` — so a fresh `x new` booted with
5
- // `X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is configured` on
6
- // every start, and its own `/dashboard` answered 401 on the first click. The scaffold declares the
7
- // routes and the roles; this is the missing third piece, and it is deliberately the smallest one
8
- // that can be honest: a viewer named by a cookie, installed in `development` and nowhere else.
4
+ // `hooks.authenticate` is the ONLY place an actor can come from, and a fresh `x new` once installed
5
+ // nothing there — `X_CONFIG_INVALID: 7 route(s) declare auth: 'required' and no authenticator is
6
+ // configured` on every start, and a 401 on its own `/dashboard`. The first answer was a viewer
7
+ // named by a cookie, installed in `development` alone, which left every other environment with no
8
+ // authenticator at all and the real path for the app's author to invent. Now the real path is the
9
+ // one installed, and the development viewer is only what a request with NO session is answered as
10
+ // under `x dev` — so `x dev` stays zero-config and nothing has to be swapped out before a deploy.
9
11
  //
10
- // The alternative — dropping `policy:` from the scaffolded routes — was refused: a dashboard that
11
- // declares no policy is registered `auth: 'public'`, which also skips `render-ssr`'s gated branch,
12
- // so the document ships with no `vary: cookie` and a shared cache may hand one visitor's page to
13
- // the next. The scaffold would teach the wrong shape to every app that starts from it.
12
+ // Dropping `policy:` from the scaffolded routes was refused: a dashboard that declares no policy is
13
+ // registered `auth: 'public'`, which also skips `render-ssr`'s gated branch, so the document ships
14
+ // with no `vary: cookie` and a shared cache may hand one visitor's page to the next.
14
15
 
15
16
  import type { GeneratedFile, NameSet } from './naming';
16
17
 
17
18
  const devActor = (
18
19
  app: NameSet,
19
- ): string => `// Who a browser is until this app issues sessions of its own.
20
+ ): string => `// Who a browser with NO session is under \`x dev\`: a viewer named by a cookie.
20
21
  //
21
- // \`hooks.authenticate\` is the one place an actor can come from. Without it every request is
22
- // anonymous, so each route declaring a \`policy:\` answers 401 and the boot warns
23
- // \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
22
+ // \`authenticator.ts\` is the real path — a session cookie, resolved by \`@ultimat3/auth\` — and it
23
+ // is installed in every environment. A fresh app issues no session until it has a sign-in page, so
24
+ // without this every route declaring a \`policy:\` answered 401 on the first \`x dev\`. This module
25
+ // only answers; \`authenticator.ts\` decides when it is asked, and it asks in \`development\` alone —
26
+ // a viewer that followed this to staging would sign every visitor in as an admin.
24
27
  //
25
- // DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
26
- // every visitor in as an admin. FAILS CLOSED (\`fallback: 'production'\`) rather than trusting
27
- // \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
28
- // NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
29
- // from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
30
- // this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
31
- // \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
32
- // actor, and a second one arriving from a cookie would decide which actor a test is about.
33
- //
34
- // REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
35
- // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
36
- import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
37
- import { configureAuthenticator, readCookie } from '@ultimat3/http';
28
+ // Delete it the day sign-in exists, and the fallback line in \`authenticator.ts\` with it.
29
+ import { type Actor, logger, readCookie } from '@ultimat3/core';
38
30
  import { DEMO_ORG_ID } from '../../shared/demo-org';
39
31
  import { roles } from '../../shared/roles';
40
32
 
@@ -82,13 +74,13 @@ export const devActorFor = (role: string): Actor => ({
82
74
  });
83
75
 
84
76
  /** The one method this reads off a request. */
85
- interface HeaderRequest {
77
+ export interface HeaderRequest {
86
78
  header(name: string): string | null;
87
79
  }
88
80
 
89
81
  /**
90
- * What \`hooks.authenticate\` is handed: the request's cookie header decides the role. Named, and
91
- * typed by the one method it reads, so a test calls it with a header and no server.
82
+ * The development viewer for a request: its cookie header decides the role. Typed by the one
83
+ * method it reads, so a test calls it with a header and no server.
92
84
  *
93
85
  * A cookie naming an undeclared role answers \`null\` — ANONYMOUS, the least a request can be —
94
86
  * and says so, with the value it read and the roles it could have named.
@@ -104,35 +96,111 @@ export const devAuthenticate = (request: HeaderRequest): Actor | null => {
104
96
  });
105
97
  return null;
106
98
  };
99
+ `;
100
+
101
+ const authModule =
102
+ (): string => `// This app's one \`@ultimat3/auth\` instance: sessions, passwords and the lockout, over the
103
+ // framework's own \`x_users\` and \`x_sessions\` tables — \`x dev\` and \`ROLE=migrate\` apply them.
104
+ //
105
+ // Sign-in is one call once the app has a page for it: \`login(appAuth(), { email, password, ip })\`
106
+ // answers \`{ token, cookie }\` — set \`cookie\` on the response and every request after it is that
107
+ // user, through \`authenticator.ts\`. \`register(appAuth(), { email, password, orgId, roles })\`
108
+ // creates one.
109
+ import { type Auth, defineAuth, postgresAuthAdapter } from '@ultimat3/auth';
110
+
111
+ let built: Auth | undefined;
112
+
113
+ /**
114
+ * Built on first use, never at import: the boot scan imports this module before the database and
115
+ * the host's shared lockout limiter are installed, and \`defineAuth\` reads both.
116
+ */
117
+ export const appAuth = (): Auth => {
118
+ built ??= defineAuth({ adapter: postgresAuthAdapter() });
119
+ return built;
120
+ };
121
+ `;
122
+
123
+ const authenticatorModule =
124
+ (): string => `// Who a request is: the session its cookie names, resolved by \`@ultimat3/auth\` — the one
125
+ // authenticator this app installs, in every environment. Everything downstream (pages, policies,
126
+ // live subscribers, MCP tools) reads the actor this returns.
127
+ //
128
+ // With NO session cookie the request is anonymous — except under \`x dev\`, where it is the
129
+ // development viewer (\`dev-actor.ts\`), so a fresh app opens its own dashboard with nothing
130
+ // configured. That fallback FAILS CLOSED (\`fallback: 'production'\`): \`tryResolveEnvironment\`'s own
131
+ // default is \`development\`, so a process naming NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would
132
+ // otherwise read as development too. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly this
133
+ // reason, and \`bun test\` sets \`NODE_ENV=test\`, where a fixture mints its own actor.
134
+ import { type Auth, authenticate } from '@ultimat3/auth';
135
+ import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
136
+ import { configureAuthenticator } from '@ultimat3/http';
137
+ import { appAuth } from './auth';
138
+ import {
139
+ DEFAULT_DEV_ROLE,
140
+ DEV_ROLE_COOKIE,
141
+ devAuthenticate,
142
+ type HeaderRequest,
143
+ } from './dev-actor';
144
+
145
+ /** The two methods this reads off a request. */
146
+ export interface SessionRequest extends HeaderRequest {
147
+ cookie(name: string): string | null;
148
+ }
149
+
150
+ export interface SessionAuthenticatorOptions {
151
+ /** The app's \`Auth\`, asked for per request so it is built after the boot, not at import. */
152
+ readonly auth: () => Auth;
153
+ /** Whether a request with no session is the development viewer rather than anonymous. */
154
+ readonly development: boolean;
155
+ }
107
156
 
108
157
  /**
109
- * Installs it, and says so — loudly, because a silent stand-in for authentication is the one thing
110
- * worse than none. Returns whether it installed, so the test can assert both halves.
158
+ * A session cookie is that session's user, or a refusal — never a fallback to anyone else:
159
+ * \`X_SESSION_EXPIRED\` for a session past its absolute or idle expiry, \`X_UNAUTHENTICATED\` for
160
+ * one that is unknown, revoked or forged, or whose user is disabled. No cookie is the development
161
+ * viewer or nobody.
111
162
  */
112
- export function installDevAuthenticator(
163
+ export const sessionAuthenticator =
164
+ (options: SessionAuthenticatorOptions) =>
165
+ async (request: SessionRequest): Promise<Actor | null> => {
166
+ const auth = options.auth();
167
+ const token = request.cookie(auth.sessions.policy.cookieName);
168
+ if (token !== null && token.length > 0) return await authenticate(auth, token);
169
+ return options.development ? devAuthenticate(request) : null;
170
+ };
171
+
172
+ /**
173
+ * Installs the session authenticator, and returns whether the development fallback is on — so the
174
+ * test can assert both halves. Loud when it is, because a stand-in for a session is the one thing
175
+ * an operator must never find in a log they did not expect it in.
176
+ */
177
+ export function installAuthenticator(
113
178
  env: Readonly<Record<string, string | undefined>> = process.env,
179
+ auth: () => Auth = appAuth,
114
180
  ): boolean {
115
- if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
116
- configureAuthenticator(devAuthenticate);
117
- logger.warn('every request is answered as a development viewer', {
118
- role: DEFAULT_DEV_ROLE,
119
- cause:
120
- 'apps/web/app/auth/dev-actor.ts installs a viewer in development only, because this app issues no session yet',
121
- fix: \`browse as someone else: document.cookie = '\${DEV_ROLE_COOKIE}=member'\`,
122
- });
123
- return true;
181
+ const development = tryResolveEnvironment({ env, fallback: 'production' }) === 'development';
182
+ configureAuthenticator(sessionAuthenticator({ auth, development }));
183
+ if (development) {
184
+ logger.warn('a request with no session is answered as a development viewer', {
185
+ role: DEFAULT_DEV_ROLE,
186
+ cause:
187
+ 'apps/web/app/auth/authenticator.ts falls back to dev-actor.ts in development, because this app has no sign-in page yet',
188
+ fix: \`browse as someone else: document.cookie = '\${DEV_ROLE_COOKIE}=member'\`,
189
+ });
190
+ }
191
+ return development;
124
192
  }
125
193
 
126
194
  // Module scope, which IS the wiring: the boot scan imports every module under \`apps/*\` before a
127
195
  // listener binds, and \`x dev\` and the container both read the configured value back at start.
128
- installDevAuthenticator();
196
+ installAuthenticator();
129
197
  `;
130
198
 
131
199
  const devActorTest =
132
- (): string => `// The two halves that make a development-only stand-in safe: it resolves the cookie, and it does
133
- // not install itself anywhere but development.
200
+ (): string => `// The development viewer is safe because of what it refuses: a role nobody declared is never the
201
+ // default viewer, and the actor it mints holds what its role holds and nothing else. WHEN it is
202
+ // asked is \`authenticator.ts\`'s, and \`authenticator.test.ts\` holds that half.
134
203
  import { setLogSink } from '@ultimat3/core';
135
- import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
136
204
  import { actorHas } from '@ultimat3/policy';
137
205
  import { expect, unitTest } from '@ultimat3/testing';
138
206
  import { roles } from '../../shared/roles';
@@ -143,7 +211,6 @@ import {
143
211
  devActorFor,
144
212
  devAuthenticate,
145
213
  devRoleFrom,
146
- installDevAuthenticator,
147
214
  } from './dev-actor';
148
215
 
149
216
  unitTest('the cookie names a DECLARED role; with no cookie it is the default viewer', () => {
@@ -173,27 +240,7 @@ unitTest('the actor it mints holds what the role map grants it, and nothing else
173
240
  expect(actorHas(devActorFor('member'), 'admin:read', roles)).toBe(false);
174
241
  });
175
242
 
176
- unitTest('it installs in development and in no other environment', () => {
177
- // This process is \`test\`, so the module-scope call at the bottom of dev-actor.ts installed
178
- // nothing — which is what keeps a fixture's own actor the only one a test can be about.
179
- expect(configuredAuthenticator()).toBeUndefined();
180
-
181
- expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
182
- expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
183
- // FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
184
- // a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
185
- // still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
186
- // for exactly the process this call simulates having none of.
187
- expect(installDevAuthenticator({})).toBe(false);
188
- expect(configuredAuthenticator()).toBeUndefined();
189
-
190
- expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
191
- expect(configuredAuthenticator()).toBe(devAuthenticate);
192
- // Process-global, so the case that installed one takes it back out.
193
- resetAuthenticator();
194
- });
195
-
196
- unitTest('what it installs answers a request from its cookie header, and no other', () => {
243
+ unitTest('it answers a request from its cookie header, and no other', () => {
197
244
  const cookie = \`\${DEV_ROLE_COOKIE}=member\`;
198
245
  const request = { header: (name: string) => (name === 'cookie' ? cookie : null) };
199
246
  expect(devAuthenticate(request)?.roles).toEqual(['member']);
@@ -217,9 +264,97 @@ unitTest('an undeclared role is answered as ANONYMOUS, and the log names what it
217
264
  });
218
265
  `;
219
266
 
220
- /** The app's development viewer, beside the roles it names. */
267
+ const authenticatorTest =
268
+ (): string => `// The real path and its one fallback: a session cookie is its user in every environment, a bad
269
+ // one is refused rather than replaced, and only development answers a request with no session.
270
+ import { defineAuth, login, memoryAuthAdapter, register } from '@ultimat3/auth';
271
+ import { configuredAuthenticator, resetAuthenticator } from '@ultimat3/http';
272
+ import { expect, unitTest } from '@ultimat3/testing';
273
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
274
+ import { installAuthenticator, sessionAuthenticator } from './authenticator';
275
+ import { DEFAULT_DEV_ROLE, DEV_ROLE_COOKIE } from './dev-actor';
276
+
277
+ /** In memory: the session half is the package's, and this test is about the wiring around it. */
278
+ const memoryAuth = () => {
279
+ const auth = defineAuth({ adapter: memoryAuthAdapter() });
280
+ return () => auth;
281
+ };
282
+
283
+ /** A request carrying exactly these cookies, through both methods the authenticator reads. */
284
+ const requestWith = (cookies: Readonly<Record<string, string>>) => ({
285
+ header: (name: string) =>
286
+ name === 'cookie' && Object.keys(cookies).length > 0
287
+ ? Object.entries(cookies)
288
+ .map(([key, value]) => \`\${key}=\${value}\`)
289
+ .join('; ')
290
+ : null,
291
+ cookie: (name: string) => cookies[name] ?? null,
292
+ });
293
+
294
+ const PASSWORD = 'a long scaffold test passphrase';
295
+
296
+ unitTest('a session cookie is the user it was issued to, in every environment', async () => {
297
+ const auth = memoryAuth();
298
+ const user = await register(auth(), {
299
+ email: 'ada@example.com',
300
+ password: PASSWORD,
301
+ orgId: DEMO_ORG_ID,
302
+ roles: ['member'],
303
+ });
304
+ const { token } = await login(auth(), { email: 'ada@example.com', password: PASSWORD });
305
+ const request = requestWith({ [auth().sessions.policy.cookieName]: token });
306
+ for (const development of [true, false]) {
307
+ const actor = await sessionAuthenticator({ auth, development })(request);
308
+ expect(actor?.id).toBe(user.id);
309
+ expect(actor?.roles).toEqual(['member']);
310
+ }
311
+ });
312
+
313
+ unitTest('a cookie naming no live session is refused, never answered as the viewer', async () => {
314
+ const auth = memoryAuth();
315
+ const request = requestWith({ [auth().sessions.policy.cookieName]: 'forged.token' });
316
+ await expect(sessionAuthenticator({ auth, development: true })(request)).rejects.toMatchObject({
317
+ code: 'X_UNAUTHENTICATED',
318
+ });
319
+ });
320
+
321
+ unitTest('no session: the development viewer under x dev, anonymous anywhere else', async () => {
322
+ const auth = memoryAuth();
323
+ const member = requestWith({ [DEV_ROLE_COOKIE]: 'member' });
324
+ expect((await sessionAuthenticator({ auth, development: true })(member))?.roles).toEqual([
325
+ 'member',
326
+ ]);
327
+ const none = requestWith({});
328
+ expect((await sessionAuthenticator({ auth, development: true })(none))?.roles).toEqual([
329
+ DEFAULT_DEV_ROLE,
330
+ ]);
331
+ expect(await sessionAuthenticator({ auth, development: false })(member)).toBeNull();
332
+ });
333
+
334
+ unitTest('it installs in every environment; only development turns the fallback on', () => {
335
+ const auth = memoryAuth();
336
+ try {
337
+ expect(installAuthenticator({ ULTIMATE_ENV: 'production' }, auth)).toBe(false);
338
+ expect(configuredAuthenticator()).toBeDefined();
339
+ expect(installAuthenticator({ ULTIMATE_ENV: 'staging' }, auth)).toBe(false);
340
+ // FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
341
+ // a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` declares
342
+ // \`ULTIMATE_ENV=development\` before this module loads, for exactly the process this simulates.
343
+ expect(installAuthenticator({}, auth)).toBe(false);
344
+ expect(installAuthenticator({ ULTIMATE_ENV: 'development' }, auth)).toBe(true);
345
+ } finally {
346
+ // Process-global, so the case that installed one takes it back out.
347
+ resetAuthenticator();
348
+ }
349
+ });
350
+ `;
351
+
352
+ /** The app's authenticator, its \`Auth\`, and the development viewer it falls back to. */
221
353
  export function authFiles(app: NameSet): readonly GeneratedFile[] {
222
354
  return [
355
+ { path: 'apps/web/app/auth/auth.ts', contents: authModule() },
356
+ { path: 'apps/web/app/auth/authenticator.ts', contents: authenticatorModule() },
357
+ { path: 'apps/web/app/auth/authenticator.test.ts', contents: authenticatorTest() },
223
358
  { path: 'apps/web/app/auth/dev-actor.ts', contents: devActor(app) },
224
359
  { path: 'apps/web/app/auth/dev-actor.test.ts', contents: devActorTest() },
225
360
  ];
@@ -0,0 +1,122 @@
1
+ // The two scripts a scaffolded app is driven by — `bun run setup` and `bun run check` — written as
2
+ // TypeScript that Bun runs, never as shell. A bash `bin/setup` was the first thing a PowerShell or
3
+ // cmd user hit after `x new`, and it failed there; a `.ts` file run by the Bun the app already
4
+ // requires is the same script on every OS. `bun run dev` needs no file: it is `x dev`.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ /** Where each script lands, and what the root `package.json`'s `scripts` run — named once. */
9
+ export const BIN_SCRIPTS = {
10
+ setup: 'bin/setup.ts',
11
+ check: 'bin/check.ts',
12
+ } as const;
13
+
14
+ /** The `package.json` script line that runs a bin file: Bun itself, so no shell interprets it. */
15
+ export const binScript = (path: string): string => `bun ${path}`;
16
+
17
+ // Every step is a TOP-LEVEL call statement, one per line — `scripts/setup-commands.ts` reads the
18
+ // documented sequence out of exactly those lines, so a helper's body (indented) or the closing
19
+ // `console.log` (a member call) is never mistaken for a step. `bun x x` is `bunx x` spelled through
20
+ // the running Bun's own path: no PATH lookup, so no shell or PATHEXT decides which `x` runs.
21
+ const binSetup =
22
+ (): string => `// Fresh clone to running. Idempotent: safe to re-run. \`bun run setup\` — Bun runs this file, so
23
+ // it is the same script under PowerShell, cmd or any POSIX shell.
24
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const root = join(import.meta.dir, '..');
28
+
29
+ /** One step, from the app root, on this terminal. A red step ends setup with its own exit code. */
30
+ const run = async (argv: readonly string[]): Promise<void> => {
31
+ const code = await Bun.spawn([...argv], { cwd: root, stdio: ['inherit', 'inherit', 'inherit'] })
32
+ .exited;
33
+ if (code !== 0) process.exit(code);
34
+ };
35
+
36
+ /** Bun itself, by the path it is running from. */
37
+ const bun = (...args: string[]): Promise<void> => run([process.execPath, ...args]);
38
+
39
+ /** The app's own CLI, as \`bunx x\` resolves it — the install below links it into node_modules. */
40
+ const x = (...args: string[]): Promise<void> => run([process.execPath, 'x', 'x', ...args]);
41
+
42
+ /** Writes a file only when it is absent, so a re-run never clobbers what a box put there. */
43
+ const ensureFile = (path: string, contents: string): void => {
44
+ if (!existsSync(join(root, path))) writeFileSync(join(root, path), contents);
45
+ };
46
+
47
+ const PER_BOX = '# per-box secrets, gitignored, wins over .env.development\\n';
48
+
49
+ // \`x db gen\` is the ONE writer of packages/db/migrations — the scaffold no longer hand-writes a
50
+ // 0000_initial.sql, because a second writer is how the source and the ledger ended up disagreeing
51
+ // about what "initial" meant. Guarded on the directory rather than on the generator being a no-op:
52
+ // this script is documented idempotent, and the guard is what makes that true here.
53
+ const hasMigration = (): boolean => {
54
+ const dir = join(root, 'packages', 'db', 'migrations');
55
+ return existsSync(dir) && readdirSync(dir).some((name) => name.endsWith('.sql'));
56
+ };
57
+
58
+ await bun('install');
59
+ ensureFile('.env.development.local', PER_BOX);
60
+ if (!hasMigration()) await x('db', 'gen', 'initial');
61
+ await x('db', 'migrate', ...process.argv.slice(2));
62
+ // \`x db seed\`, never a plain script: the CLI owns the connection, so this reaches the same
63
+ // embedded PGlite the migration above just wrote to. A plain script goes through \`db()\`, which
64
+ // needs a \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres.
65
+ await x('db', 'seed');
66
+ // The file AGENTS.md tells an agent facts live in, and \`x dev\` prints the path of. It is a
67
+ // projection of the loaded app, so \`x new\` cannot write it — node_modules does not exist yet.
68
+ // \`x verify\`'s manifest step refuses its absence (X_MANIFEST_MISSING).
69
+ await x('manifest');
70
+ console.log('setup complete — next: bun run dev');
71
+ `;
72
+
73
+ const binCheck =
74
+ (): string => `// The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
75
+ // \`bun run check\` — Bun runs this file, so it is the same gate under PowerShell, cmd or bash.
76
+ import { join } from 'node:path';
77
+
78
+ const root = join(import.meta.dir, '..');
79
+ const args = process.argv.slice(2);
80
+
81
+ /** The app's own CLI, as \`bunx x\` resolves it, from the app root; resolves to its exit code. */
82
+ const x = async (...argv: string[]): Promise<number> => {
83
+ const child = Bun.spawn([process.execPath, 'x', 'x', ...argv], {
84
+ cwd: root,
85
+ stdio: ['inherit', 'inherit', 'inherit'],
86
+ });
87
+ // The terminal already hands Ctrl-C to the child; this process only waits for its verdict. A
88
+ // SIGTERM is forwarded, so a CI timeout stops the gate instead of orphaning it mid-step.
89
+ const wait = (): void => {};
90
+ const stop = (): void => {
91
+ child.kill('SIGTERM');
92
+ };
93
+ process.on('SIGINT', wait);
94
+ process.on('SIGTERM', stop);
95
+ const code = await child.exited;
96
+ process.off('SIGINT', wait);
97
+ process.off('SIGTERM', stop);
98
+ return code;
99
+ };
100
+
101
+ // The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
102
+ // against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED
103
+ // and the very first gate anyone runs on a brand-new app is red for a reason that has nothing to
104
+ // do with their code.
105
+ //
106
+ // \`--json\` is forwarded to BOTH, or the contract breaks: the build's human renderer followed by
107
+ // the gate's JSON is neither on stdout. Both commands emit one object; a reader takes the last line.
108
+ // \`--no-preflight\`: the build's own preflight is x verify's first six steps (typecheck, lint,
109
+ // boundaries, filesize, package-shape, errors), and the gate below runs them anyway.
110
+ const json = args.some((arg) => arg === '--json' || arg === '-j') ? ['--json'] : [];
111
+ const built = await x('build', '--target', 'static', '--no-preflight', ...json);
112
+ if (built !== 0) process.exit(built);
113
+ process.exit(await x('verify', ...args));
114
+ `;
115
+
116
+ /** The bin scripts, in the order a newcomer runs them. */
117
+ export function binFiles(): readonly GeneratedFile[] {
118
+ return [
119
+ { path: BIN_SCRIPTS.setup, contents: binSetup() },
120
+ { path: BIN_SCRIPTS.check, contents: binCheck() },
121
+ ];
122
+ }
@@ -6,7 +6,7 @@
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
 
8
8
  const feature = (app: NameSet): string => `---
9
- description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bin/check\`.
9
+ description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bun run check\`.
10
10
  argument-hint: <what you want built or fixed, plain language>
11
11
  allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
12
12
  ---
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
16
16
  You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
17
17
  anything — it is the short form of every rule below, and it wins where the two disagree.
18
18
 
19
- **Done means \`bin/check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
19
+ **Done means \`bun run check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
20
20
  Report what you actually ran, never what you assume passed.
21
21
 
22
22
  ## Request
@@ -77,15 +77,15 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
77
77
  ## 4. Gate it
78
78
 
79
79
  \`\`\`sh
80
- bin/check # the gate. green = shippable
81
- bin/check --json # the same, machine-readable
80
+ bun run check # the gate. green = shippable
81
+ bun run check --json # the same, machine-readable
82
82
  \`\`\`
83
83
 
84
- \`bin/check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
84
+ \`bun run check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
85
85
  the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
86
86
  its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
87
87
  a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
88
- \`bin/setup\` and then \`bin/check\`, nothing else.
88
+ \`bun run setup\` and then \`bun run check\`, nothing else.
89
89
 
90
90
  Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
91
91
  before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
@@ -118,7 +118,7 @@ ISO code, never a float. Bun only.
118
118
  Primitive: <which of the eight> Slice: <dir>
119
119
  Generated: <the x g invocations you ran>
120
120
  Changed: <files>
121
- Gate: bin/check ✓ | ✗ <failing steps>
121
+ Gate: bun run check ✓ | ✗ <failing steps>
122
122
  Deferred: <what you did not do, and why> [never omit this line]
123
123
  \`\`\`
124
124
  `;
@@ -172,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
172
172
  - What to add, next to the source as \`<file>.test.ts\`. Command to run it.
173
173
 
174
174
  ## Done when
175
- - Acceptance criteria, ending in \`bin/check\` green.
175
+ - Acceptance criteria, ending in \`bun run check\` green.
176
176
 
177
177
  ## Risks
178
178
  - Anything the executor must decide, and every claim in the ask the code disproves.
@@ -185,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
185
185
  - No checkboxes. The plan is a map, not a tracker.
186
186
  - The plan must obey the app's own rules — one way to do each thing, generators over hand-written
187
187
  files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
188
- every new failure, and \`bin/check\` green as the last line of *Done when*.
188
+ every new failure, and \`bun run check\` green as the last line of *Done when*.
189
189
 
190
190
  ## Output
191
191
 
@@ -202,7 +202,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
202
202
 
203
203
  # /verify
204
204
 
205
- Run \`bin/check\`.
205
+ Run \`bun run check\`.
206
206
 
207
207
  It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
208
208
  (\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
@@ -213,7 +213,7 @@ Green: say so and stop.
213
213
 
214
214
  Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
215
215
  executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
216
- when the cause is not enough. \`bin/check --json\` gives the same steps machine-readably; \`x doctor\`
216
+ when the cause is not enough. \`bun run check --json\` gives the same steps machine-readably; \`x doctor\`
217
217
  covers the case where the environment, not the code, is what is broken.
218
218
 
219
219
  Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
@@ -10,7 +10,7 @@ import { SECRETS_KEY_FILE } from '@ultimat3/core';
10
10
  import { PREBUILT_COMMAND } from '../serve-prebuilt-paths';
11
11
  import type { GeneratedFile, NameSet } from './naming';
12
12
  import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
13
- import { helmFiles } from './scaffold-helm';
13
+ import { helmFiles, RUNTIME_UID } from './scaffold-helm';
14
14
 
15
15
  const dockerfile = (
16
16
  app: NameSet,
@@ -32,6 +32,8 @@ const dockerfile = (
32
32
  # from the file itself, and without it the frozen install cannot find what the lockfile names.
33
33
  # The tag names the series for the reader; the digest is what builds — a tag is a pointer its
34
34
  # publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
35
+ # The digest is \`oven/bun:1.4.2-alpine\`'s, the patch the framework that wrote this file was
36
+ # tested on — the same patch as \`engines.bun\` in package.json.
35
37
  FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS manifests
36
38
  WORKDIR /app
37
39
  COPY . .
@@ -92,9 +94,11 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
92
94
  # \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
93
95
  # PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
94
96
  # is a non-root process failing at boot on a directory it is the only one that ever writes.
95
- RUN mkdir -p /app/.x && chown -R bun:bun /app/.x
97
+ # Numeric, and the same number docker/helm/values.yaml runs every pod as: ${RUNTIME_UID} is the base
98
+ # image's \`bun\` user, and a numeric USER is one a kubelet can check against runAsNonRoot.
99
+ RUN mkdir -p /app/.x && chown -R ${RUNTIME_UID}:${RUNTIME_UID} /app/.x
96
100
 
97
- USER bun
101
+ USER ${RUNTIME_UID}:${RUNTIME_UID}
98
102
  # apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
99
103
  # exits; every other role serves until SIGTERM.
100
104
  ENTRYPOINT ["bun", "apps/web/server.ts"]
@@ -190,9 +194,11 @@ x build --target static --out dist/static # one HTML file per \`render: 'stati
190
194
  x build --target binary --out dist/app # a single executable, no Bun install needed
191
195
  \`\`\`
192
196
 
193
- The binary bundles the framework, not the app: the registries are filled by scanning
194
- \`apps/*/{site,app,api,shared}\` at boot, so it is a launcher that must be **started from the app
195
- root**, with the source tree beside it. The image is the self-contained artifact.
197
+ The binary bundles neither the framework nor the app: it is a launcher. \`@ultimat3/*\` stays
198
+ external and loads from the app's own \`node_modules\`, the app's \`package.json\` and
199
+ \`tsconfig.json\` are read at run time, and the registries are filled by scanning
200
+ \`apps/*/{site,app,api,shared}\` at boot. So it must be **started from the app root**, with the
201
+ source tree and an installed \`node_modules\` beside it. The image is the self-contained artifact.
196
202
 
197
203
  ## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
198
204
 
@@ -224,10 +224,14 @@ unitTest('the tiles are the framework registries, counted', async () => {
224
224
  unitTest('the table is the route table: path, surface and render mode per route', async () => {
225
225
  const t = useT();
226
226
  const view = await renderRoute(page, { url });
227
- expect(view.html.match(/<tr data-row=/g)).toHaveLength(1);
228
- expect(view.html).toMatch(
229
- /<tr data-row="\\/dashboard"><td>\\/dashboard<\\/td><td>app<\\/td><td>ssr<\\/td>/,
230
- );
227
+ expect(view.html.match(/<tr[^>]*\\sdata-row=/g)).toHaveLength(1);
228
+ // Each cell ends in its value; on a phone a card label rides in front of it, hidden from AT.
229
+ const row = view.html.match(/<tr[^>]*data-row="\\/dashboard">([\\s\\S]*?)<\\/tr>/)?.[1] ?? '';
230
+ expect([...row.matchAll(/([^>]*)<\\/td>/g)].map((cell) => cell[1])).toEqual([
231
+ '/dashboard',
232
+ 'app',
233
+ 'ssr',
234
+ ]);
231
235
  expect(view.text).toContain(t('app.dashboard.routesCaption'));
232
236
  expect(view.meta.title).toBe(t('app.dashboard.title'));
233
237
  // The sidebar knows where it is.