@ultimat3/cli 6.0.0 → 8.0.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
@@ -0,0 +1,142 @@
1
+ // The `unzoned-date` guard `x new` ships: no date is formatted without an explicit IANA zone.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — `toLocaleDateString('en-US')`
3
+ // in a scaffolded page was green on `x verify`, and it renders the SERVER's zone, so the same row
4
+ // reads as two different days depending on which container answered.
5
+
6
+ import { guardCode } from './guard';
7
+ import type { GeneratedFile } from './naming';
8
+
9
+ /**
10
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
11
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
12
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
13
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
14
+ */
15
+ const NAME = 'unzoned-date';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// unzoned-date: a date is stored in UTC and formatted with an explicit IANA zone, never with the
20
+ // host's ambient one. \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside
21
+ // the \`boundaries\` step — nothing registers this file, so nothing can forget to.
22
+
23
+ import type { Finding, Guard } from '@ultimat3/cli';
24
+
25
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
+ const CODE = '${CODE}';
27
+
28
+ /** Every call whose output depends on a zone. The \`(\` is the start of the argument list. */
29
+ const FORMATTER = /(?:\\.toLocale(?:Date|Time)?String|Intl\\.DateTimeFormat)\\s*\\(/g;
30
+
31
+ export interface SourceFile {
32
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
33
+ readonly path: string;
34
+ readonly source: string;
35
+ }
36
+
37
+ /**
38
+ * Comments blanked IN PLACE — not deleted — so the line number a finding reports still points at
39
+ * the source line. What it does not blank is a string body: a call spelled inside a quoted string
40
+ * is reported, which is the one false positive this rule can produce and the reason its own test
41
+ * lives in \`guards/\`, which nothing here scans.
42
+ */
43
+ const blank = (text: string): string =>
44
+ text
45
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
46
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
47
+
48
+ /**
49
+ * The call's own argument list, from its \`(\` to the \`)\` that closes it. Depth-counted rather
50
+ * than "up to the next \`)\`": \`toLocaleDateString(locale, { timeZone: zoneFor(x) })\` closes twice
51
+ * before the one that ends the call, and reading only the first would report a zoned call.
52
+ */
53
+ function argumentsOf(text: string, open: number): string {
54
+ let depth = 0;
55
+ for (let index = open; index < text.length; index += 1) {
56
+ const char = text[index];
57
+ if (char === '(') depth += 1;
58
+ else if (char === ')') {
59
+ depth -= 1;
60
+ if (depth === 0) return text.slice(open + 1, index);
61
+ }
62
+ }
63
+ return text.slice(open + 1);
64
+ }
65
+
66
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
67
+
68
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
69
+ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
70
+ const findings: Finding[] = [];
71
+ for (const file of files) {
72
+ const text = blank(file.source);
73
+ for (const match of text.matchAll(FORMATTER)) {
74
+ const open = match.index + match[0].length - 1;
75
+ if (argumentsOf(text, open).includes('timeZone')) continue;
76
+ const line = lineOf(text, match.index);
77
+ findings.push({
78
+ code: CODE,
79
+ cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
80
+ fix: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
81
+ at: file.path,
82
+ });
83
+ }
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ export const guard: Guard = {
89
+ summary: 'a date is never formatted without an explicit IANA time zone',
90
+ async check(root) {
91
+ const files: SourceFile[] = [];
92
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
93
+ cwd: root,
94
+ absolute: false,
95
+ })) {
96
+ const path = entry.split('\\\\').join('/');
97
+ // A test's subject is often the wrong shape on purpose, and \`node_modules\` is not this
98
+ // app's source. Neither exclusion hides a rendered date from a user.
99
+ if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
100
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
101
+ }
102
+ return unzonedDates(files);
103
+ },
104
+ };
105
+ `;
106
+
107
+ const test =
108
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
109
+ // a green gate over the convention it was written to enforce.
110
+
111
+ import { expect, unitTest } from '@ultimat3/testing';
112
+ import { unzonedDates } from './unzoned-date';
113
+
114
+ const file = (source: string) => [{ path: 'apps/web/app/dashboard/page.tsx', source }];
115
+
116
+ unitTest('toLocaleDateString with no timeZone is refused', () => {
117
+ const findings = unzonedDates(file("const shown = at.toLocaleDateString('en-US');"));
118
+ expect(findings).toHaveLength(1);
119
+ expect(findings[0]?.code).toBe('${CODE}');
120
+ expect(findings[0]?.fix).toContain('timeZone');
121
+ });
122
+
123
+ unitTest('an explicit zone satisfies it, even nested behind another call', () => {
124
+ const zoned = "at.toLocaleDateString('en-US', { timeZone: zoneFor(actor) });";
125
+ expect(unzonedDates(file(zoned))).toEqual([]);
126
+ });
127
+
128
+ unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
129
+ expect(unzonedDates(file("new Intl.DateTimeFormat('en-US').format(at);"))).toHaveLength(1);
130
+ expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
131
+ });
132
+
133
+ unitTest('a commented-out call is a note, not a call', () => {
134
+ expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
135
+ });
136
+ `;
137
+
138
+ /** `guards/unzoned-date.ts` and its test. No index, no registry — the directory registers it. */
139
+ export const unzonedDateGuardFiles = (): readonly GeneratedFile[] => [
140
+ { path: 'guards/unzoned-date.ts', contents: source() },
141
+ { path: 'guards/unzoned-date.test.ts', contents: test() },
142
+ ];
@@ -30,6 +30,7 @@ export type { QueryOptions } from './query';
30
30
  export { queryFiles } from './query';
31
31
  export type { ResourceOptions } from './resource';
32
32
  export { resourceFiles } from './resource';
33
+ export { formIslandFiles } from './resource-form-island';
33
34
  export type { RouteOptions, Surface } from './route';
34
35
  export { routeFiles } from './route';
35
36
  export { appFiles } from './scaffold-app';
@@ -38,6 +39,9 @@ export { claudeAgentFiles } from './scaffold-claude-agents';
38
39
  export { claudeCommandFiles } from './scaffold-claude-commands';
39
40
  export { containerFiles } from './scaffold-container';
40
41
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
42
+ export { entryFiles } from './scaffold-entries';
43
+ // The four guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
44
+ export { scaffoldGuardFiles } from './scaffold-guards';
41
45
  export { i18nIndex } from './scaffold-i18n';
42
46
  export { repoFiles } from './scaffold-repo';
43
47
  export type { SliceModule } from './slice-foundation';
@@ -0,0 +1,76 @@
1
+ // TEST-ONLY. An app root on disk holding emitted files, with the packages an island imports
2
+ // resolvable BY SPECIFIER the way a real app resolves them — so a template that emits
3
+ // `import { Button } from '@ultimat3/ui'` is proven by a build that actually resolves it, and not
4
+ // by a string assertion that the import is present.
5
+
6
+ // `node:` by necessity, and SYNC by necessity: `[Symbol.dispose]` cannot await, so the teardown
7
+ // half has to be synchronous — and Bun ships neither a path API nor a `symlink`.
8
+ import { mkdirSync, rmSync, symlinkSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import type { GeneratedFile } from './naming';
11
+
12
+ /** `packages/cli/src/templates` → the repo root, four hops up. */
13
+ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..');
14
+
15
+ /**
16
+ * INSIDE the checkout, and measured rather than chosen: the identical fixture under `os.tmpdir()`
17
+ * fails every `@ultimat3/*` and every relative import inside it with `Could not resolve`, because
18
+ * `Bun.build`'s resolver is scoped to the project `bun test` was started in and an app root outside
19
+ * it cannot reach its own `node_modules`. `.prerender-fixture` is the same shape for the same
20
+ * reason. The leading dot keeps it out of every `tsc` wildcard include.
21
+ */
22
+ const FIXTURE_ROOT = join(REPO_ROOT, 'packages', 'cli', '.island-fixture');
23
+
24
+ /** The package the fixture lives inside. Linking it would aim a symlink at its own ancestor. */
25
+ const SELF = 'cli';
26
+
27
+ export interface FixtureApp extends Disposable {
28
+ /** Absolute path of the app root — what `buildIslands` globs from. */
29
+ readonly path: string;
30
+ }
31
+
32
+ /**
33
+ * Symlinks rather than a `bun install`: the emitted island must resolve THIS working copy of
34
+ * `@ultimat3/ui`, and an install in a fixture directory would fetch the registry's last release
35
+ * and quietly prove nothing about the change under test. Every workspace is linked, not a chosen
36
+ * few — a template that grows an import should build, not fail on a list nobody updated.
37
+ */
38
+ function linkDependencies(root: string): void {
39
+ const scope = join(root, 'node_modules', '@ultimat3');
40
+ mkdirSync(scope, { recursive: true });
41
+ const packages = join(REPO_ROOT, 'packages');
42
+ for (const entry of new Bun.Glob('*/package.json').scanSync({ cwd: packages })) {
43
+ const name = entry.slice(0, entry.indexOf('/'));
44
+ if (name === SELF) continue;
45
+ symlinkSync(join(packages, name), join(scope, name), 'dir');
46
+ }
47
+ // Resolved, never spelled as a path: the installer's layout is its own business and a hardcoded
48
+ // `node_modules/solid-js` is a fixture that breaks on a linker change rather than on a real one.
49
+ symlinkSync(
50
+ dirname(Bun.resolveSync('solid-js/package.json', REPO_ROOT)),
51
+ join(root, 'node_modules', 'solid-js'),
52
+ 'dir',
53
+ );
54
+ }
55
+
56
+ /**
57
+ * `Disposable`, so the idiom is `using root = await fixtureAppRoot(label, files)`. `label` is the
58
+ * caller's, and is what keeps two test FILES off one directory: the path is fixed rather than
59
+ * random, because a random one cannot be named in `.gitignore` and a crashed run leaves it behind.
60
+ */
61
+ export async function fixtureAppRoot(
62
+ label: string,
63
+ files: readonly GeneratedFile[],
64
+ ): Promise<FixtureApp> {
65
+ const path = join(FIXTURE_ROOT, label);
66
+ rmSync(path, { recursive: true, force: true });
67
+ mkdirSync(path, { recursive: true });
68
+ linkDependencies(path);
69
+ for (const file of files) await Bun.write(join(path, file.path), String(file.contents));
70
+ return {
71
+ path,
72
+ [Symbol.dispose]: (): void => {
73
+ rmSync(path, { recursive: true, force: true });
74
+ },
75
+ };
76
+ }
@@ -1,7 +1,8 @@
1
1
  // `x g island <name>` — the one file on a route that ships JavaScript. Not a ninth primitive and
2
2
  // not a component generator: an island is a client ENTRY POINT, so what the scaffold has to get
3
- // right is the filename (the bundler discovers by it) and the `mount` export (the hydration
4
- // runtime calls it by name). Both are pinned by the emitted test.
3
+ // right is the filename (the bundler discovers by it), the `mount` export (the hydration runtime
4
+ // calls it by name) and what `mount` DOES — Solid's `render`, the one client shape the island build
5
+ // compiles. All three are pinned by the emitted test, which builds the chunk and mounts it.
5
6
 
6
7
  import type { GeneratedFile } from './naming';
7
8
  import { kebab, pascal } from './naming';
@@ -11,6 +12,18 @@ export interface IslandOptions {
11
12
  readonly dir: string;
12
13
  }
13
14
 
15
+ /**
16
+ * `join(import.meta.dir, '..', …)` back to the app root, one hop per directory segment. The
17
+ * emitted test names the island app-root-relative because that is how `discoverIslands` reports it,
18
+ * so the two spellings have to agree or `mountIsland` reports a file it did not build.
19
+ */
20
+ export const upToAppRoot = (dir: string): string =>
21
+ dir
22
+ .split('/')
23
+ .filter((part) => part.length > 0)
24
+ .map(() => "'..'")
25
+ .join(', ');
26
+
14
27
  const islandSource = (name: string): string => {
15
28
  const Name = pascal(name);
16
29
  return `// ${Name}: the interactive half of an otherwise static page, and the only module on this
@@ -21,39 +34,137 @@ const islandSource = (name: string): string => {
21
34
  // A string has no import edge, so nothing follows one into this file and the page's bundle graph
22
35
  // stays the page's (axiom 6). WHEN it wakes is the route's \`hydrate\`, never a declaration here.
23
36
 
37
+ import type { JSX } from 'solid-js';
38
+ import { createSignal } from 'solid-js';
39
+ import { render } from 'solid-js/web';
40
+ import styles from './${name}.module.scss';
41
+
24
42
  /** What the server sends. Declared here AND in the page's \`island({ props })\` — both, or neither. */
25
43
  export interface ${Name}Props {
26
44
  /** Already translated: this runs in the browser, where \`t()\`'s catalog is not. */
27
45
  readonly label: string;
28
46
  }
29
47
 
48
+ /**
49
+ * Solid, and not hand-written DOM: reactivity is a COMPILE-time contract that \`babel-preset-solid\`
50
+ * fulfils inside the island build, which is what makes \`count()\` read below update that one text
51
+ * node and nothing around it. A component written against an eager JSX factory reads every signal
52
+ * once and never again — it renders, and then it is a photograph.
53
+ */
54
+ function ${Name}(props: ${Name}Props): JSX.Element {
55
+ const [count, setCount] = createSignal(0);
56
+ return (
57
+ <p class={styles.panel}>
58
+ <button type="button" class={styles.trigger} onClick={() => setCount(count() + 1)}>
59
+ {props.label}
60
+ </button>
61
+ <output data-role="count">{count()}</output>
62
+ </p>
63
+ );
64
+ }
65
+
30
66
  /**
31
67
  * The one export the hydration runtime calls — \`import(entry).then((m) => m.mount(el, props))\`.
32
- * \`el\` is the wrapper the page rendered, with the server's own markup already inside it, so a
33
- * mount that replaces the markup instead of taking it over is a visible flash on every load.
68
+ * \`el\` is the wrapper the page rendered, with the server's own markup already inside it.
69
+ *
70
+ * The shell is cleared first, and that line is load-bearing: Solid's \`render\` APPENDS when the
71
+ * container already has children, so without it the server's markup stays on screen above a
72
+ * second, live copy of the same thing.
34
73
  */
35
74
  export function mount(el: HTMLElement, props: ${Name}Props): void {
36
- el.textContent = props.label;
37
- el.addEventListener('click', () => {
38
- // \`dataset.open\`, not \`dataset['open']\`: the bracket form is lint/complexity/useLiteralKeys,
39
- // which the app's own \`biome check\` fails on — twice, in the one file every island copies.
40
- el.dataset.open = el.dataset.open === 'true' ? 'false' : 'true';
41
- });
75
+ el.textContent = '';
76
+ render(() => <${Name} {...props} />, el);
42
77
  }
43
78
  `;
44
79
  };
45
80
 
81
+ const islandStyle =
82
+ (): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step, not by lint — a dark-theme bug and
83
+ // a lint failure. Scoped by the island build, with the class names the server hashed.
84
+ @use '@ultimat3/ui/tokens' as tokens;
85
+
86
+ .panel {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: tokens.space(2);
90
+ }
91
+
92
+ .trigger {
93
+ padding: tokens.space(2);
94
+ border-radius: tokens.radius('sm');
95
+ background: tokens.role('surface-raised');
96
+ color: tokens.role('fg');
97
+ }
98
+ `;
99
+
46
100
  const islandTest = (
47
101
  name: string,
48
- ): string => `// The runtime boots an island by calling \`mount\` on whatever the module exports. A renamed or
49
- // deleted export is a page that renders, serves, passes every other gate and does nothing when
50
- // clicked — which is exactly the failure nothing else in the build can see.
102
+ dir: string,
103
+ ): string => `// The island the browser actually runs. \`mountIsland\` builds this entry with the same
104
+ // \`buildIslands\` that \`x build\` and \`x dev\` use, imports the emitted chunk the way the hydration
105
+ // runtime does, and drives \`mount\` against a DOM small enough to read.
106
+ //
107
+ // Importing the module and asserting \`typeof mount === 'function'\` proves the file exists, and a
108
+ // file that exists is exactly what ships dead: a renamed export, a dropped handler and a signal
109
+ // that never reaches the DOM all pass that test and none of them survive this one.
110
+
111
+ import { join } from 'node:path';
112
+ import { buildIslands } from '@ultimat3/cli';
113
+ import {
114
+ afterAll,
115
+ beforeAll,
116
+ describe,
117
+ expect,
118
+ type MountedIsland,
119
+ mountIsland,
120
+ test,
121
+ } from '@ultimat3/testing';
51
122
 
52
- import { expect, unitTest } from '@ultimat3/testing';
53
- import * as entry from './${name}.island';
123
+ const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
124
+ const ISLAND = '${dir}/${name}.island.tsx';
125
+
126
+ let mounted: MountedIsland;
127
+
128
+ // The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
129
+ // \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
130
+ // per case, so the slow work goes where it can be given one and every case shares the result.
131
+ beforeAll(async () => {
132
+ mounted = await mountIsland({
133
+ build: buildIslands,
134
+ root: APP_ROOT,
135
+ file: ISLAND,
136
+ props: { label: 'Open' },
137
+ // What the server rendered inside the island's wrapper. \`mount\` replaces it.
138
+ shell: '<span>Open</span>',
139
+ });
140
+ }, 60_000);
54
141
 
55
- unitTest('${name}.island exports the mount the hydration runtime calls', () => {
56
- expect(typeof entry.mount).toBe('function');
142
+ // The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
143
+ //
144
+ // \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
145
+ // cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
146
+ // bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
147
+ // undefined is not an object\` that says nothing, and that second line is the one a tail reads.
148
+ // Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
149
+ afterAll(() => {
150
+ mounted?.[Symbol.dispose]();
151
+ });
152
+
153
+ describe('the ${name} island', () => {
154
+ test('mount replaces the server shell', () => {
155
+ expect(mounted.find('span')).toBeNull();
156
+ // Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
157
+ // a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
158
+ expect(mounted.code).not.toMatch(/\\bReact\\b/);
159
+ });
160
+
161
+ test('a click reaches the DOM through the signal', () => {
162
+ expect(mounted.text('[data-role="count"]')).toBe('0');
163
+ // \`false\` means no handler ran — an island whose onClick never reached the DOM looks identical
164
+ // to a selector typo otherwise.
165
+ expect(mounted.fire('button', 'click')).toBe(true);
166
+ expect(mounted.text('[data-role="count"]')).toBe('1');
167
+ });
57
168
  });
58
169
  `;
59
170
 
@@ -62,6 +173,7 @@ export function islandFiles(rawName: string, options: IslandOptions): readonly G
62
173
  const dir = options.dir.replace(/\/+$/, '');
63
174
  return [
64
175
  { path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
65
- { path: `${dir}/${name}.island.test.ts`, contents: islandTest(name) },
176
+ { path: `${dir}/${name}.module.scss`, contents: islandStyle() },
177
+ { path: `${dir}/${name}.island.test.ts`, contents: islandTest(name, dir) },
66
178
  ];
67
179
  }