@ultimat3/cli 22.15.0 → 23.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.
- package/CLAUDE.md +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The `undefined-style-class` guard `x new` ships: `styles.<name>` is a class its `.module.scss`
|
|
2
|
+
// COMPILES. `cx` drops an `undefined`, so a dead class renders nothing wrong and says nothing —
|
|
3
|
+
// the deployed demo carried one (`styles.wordmark`) through every gate until this guard read it.
|
|
4
|
+
// The class list is the build's own (`GuardSources.compiled`), so a mixin-generated class counts.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'undefined-style-class';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// undefined-style-class: a class a component asks its stylesheet for is a class the sheet compiles.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Read from the COMPILED sheet, so a class a mixin generates or \`&-suffix\` nests is one, and a
|
|
19
|
+
// class inside \`:global()\` is not. A computed member (\`styles[name]\`) is out of scope: its key is
|
|
20
|
+
// only known when the component runs. A sheet that does not compile is the build's refusal.
|
|
21
|
+
|
|
22
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
23
|
+
|
|
24
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
25
|
+
const CODE = '${CODE}';
|
|
26
|
+
|
|
27
|
+
export interface MarkupFile {
|
|
28
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly tsx: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The classes a stylesheet compiles, by app-root-relative path; \`undefined\` when it does not. */
|
|
34
|
+
export type CompiledClasses = (sheet: string) => Promise<readonly string[] | undefined>;
|
|
35
|
+
|
|
36
|
+
const blank = (text: string): string => text.replaceAll(/[^\\n]/g, ' ');
|
|
37
|
+
|
|
38
|
+
/** Comments blanked rather than removed, so a reported line number still points at the source. */
|
|
39
|
+
const blankComments = (tsx: string): string =>
|
|
40
|
+
tsx.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, blank).replaceAll(/(?<![:\\w'"\`])\\/\\/[^\\n]*/g, blank);
|
|
41
|
+
|
|
42
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
43
|
+
|
|
44
|
+
/** \`import styles from './page.module.scss'\` — the binding, and the sheet it reads. */
|
|
45
|
+
const SHEET_IMPORT =
|
|
46
|
+
/import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"](\\.{1,2}\\/[^'"]+\\.module\\.s?css)['"]/g;
|
|
47
|
+
|
|
48
|
+
/** \`a/b/page.tsx\` + \`../x.module.scss\` → \`a/x.module.scss\`. */
|
|
49
|
+
const resolve = (from: string, specifier: string): string => {
|
|
50
|
+
const parts = from.split('/').slice(0, -1);
|
|
51
|
+
for (const segment of specifier.split('/')) {
|
|
52
|
+
if (segment === '..') parts.pop();
|
|
53
|
+
else if (segment !== '.') parts.push(segment);
|
|
54
|
+
}
|
|
55
|
+
return parts.join('/');
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The declared class a misspelt one most likely meant: one edit away, or the same letters. */
|
|
59
|
+
const nearest = (name: string, declared: readonly string[]): string | undefined => {
|
|
60
|
+
const fold = (text: string): string => text.toLowerCase().replaceAll(/[-_]/g, '');
|
|
61
|
+
return (
|
|
62
|
+
declared.find((candidate) => fold(candidate) === fold(name)) ??
|
|
63
|
+
declared.find(
|
|
64
|
+
(candidate) =>
|
|
65
|
+
Math.abs(candidate.length - name.length) <= 1 &&
|
|
66
|
+
(candidate.startsWith(name.slice(0, -1)) || name.startsWith(candidate.slice(0, -1))),
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Pure but for \`classesOf\`, which the caller supplies — so the rule is testable without Sass. */
|
|
72
|
+
export async function undefinedStyleClasses(
|
|
73
|
+
files: readonly MarkupFile[],
|
|
74
|
+
classesOf: CompiledClasses,
|
|
75
|
+
): Promise<readonly Finding[]> {
|
|
76
|
+
const findings: Finding[] = [];
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const tsx = blankComments(file.tsx);
|
|
79
|
+
for (const imported of tsx.matchAll(SHEET_IMPORT)) {
|
|
80
|
+
const binding = imported[1] ?? '';
|
|
81
|
+
const sheet = resolve(file.path, imported[2] ?? '');
|
|
82
|
+
const declared = await classesOf(sheet);
|
|
83
|
+
if (declared === undefined) continue;
|
|
84
|
+
// \`styles.hero\` and \`styles['hero-title']\` — a static key either way. \`styles[name]\` has no
|
|
85
|
+
// quote after the bracket and is not matched. A path is not a read: \`'./shell.module.scss'\`
|
|
86
|
+
// holds \`shell.module\`, so a name that follows a slash, a quote or a dash is skipped.
|
|
87
|
+
const read = new RegExp(
|
|
88
|
+
\`(?<![\\\\w$./'"-])\${binding.replaceAll('$', '\\\\$')}(?:\\\\.([A-Za-z_$][\\\\w$]*)|\\\\[\\\\s*(['"])([^'"]+)\\\\2\\\\s*\\\\])\`,
|
|
89
|
+
'g',
|
|
90
|
+
);
|
|
91
|
+
const reported = new Set<string>();
|
|
92
|
+
for (const use of tsx.matchAll(read)) {
|
|
93
|
+
const name = use[1] ?? use[3] ?? '';
|
|
94
|
+
if (declared.includes(name) || reported.has(name)) continue;
|
|
95
|
+
reported.add(name);
|
|
96
|
+
const line = lineOf(tsx, use.index);
|
|
97
|
+
const meant = nearest(name, declared);
|
|
98
|
+
findings.push({
|
|
99
|
+
code: CODE,
|
|
100
|
+
cause: \`\${file.path}:\${line} reads \${binding}.\${name}, and \${sheet} compiles no class \\\`\${name}\\\` — the element renders with no class, and nothing says so\`,
|
|
101
|
+
fix:
|
|
102
|
+
meant === undefined
|
|
103
|
+
? \`add \\\`.\${name} { … }\\\` to \${sheet}, or delete the read at \${file.path}:\${line}, then: x verify\`
|
|
104
|
+
: \`\${binding}\${/^[A-Za-z_$][\\w$]*$/.test(meant) ? \`.\${meant}\` : \`['\${meant}']\`} — at \${file.path}:\${line}; \${sheet} declares \\\`.\${meant}\\\`, then: x verify\`,
|
|
105
|
+
at: \`\${file.path}:\${line}\`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const guard: Guard = {
|
|
114
|
+
summary: 'a class a component reads off its stylesheet is a class that sheet compiles',
|
|
115
|
+
async check(_root, sources) {
|
|
116
|
+
const markup = await sources.files('{apps,packages}/**/*.tsx');
|
|
117
|
+
return undefinedStyleClasses(
|
|
118
|
+
markup.map((file) => ({ path: file.path, tsx: file.text })),
|
|
119
|
+
async (sheet) => {
|
|
120
|
+
const compiled = await sources.compiled(sheet);
|
|
121
|
+
return compiled === undefined ? undefined : Object.keys(compiled.classes);
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const test =
|
|
129
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
130
|
+
// a green gate over the convention it was written to enforce.
|
|
131
|
+
|
|
132
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
133
|
+
import { undefinedStyleClasses } from './undefined-style-class';
|
|
134
|
+
|
|
135
|
+
const SHEETS: Readonly<Record<string, readonly string[]>> = {
|
|
136
|
+
'apps/web/site/page.module.scss': ['hero', 'hero-title', 'tone-accent'],
|
|
137
|
+
'apps/web/shared/shell.module.scss': ['shell'],
|
|
138
|
+
};
|
|
139
|
+
const classesOf = async (sheet: string) => SHEETS[sheet];
|
|
140
|
+
|
|
141
|
+
const page = (body: string, imports = "import styles from './page.module.scss';") => [
|
|
142
|
+
{ path: 'apps/web/site/page.tsx', tsx: \`\${imports}\\n\${body}\\n\` },
|
|
143
|
+
];
|
|
144
|
+
const check = (body: string, imports?: string) =>
|
|
145
|
+
undefinedStyleClasses(page(body, imports), classesOf);
|
|
146
|
+
|
|
147
|
+
unitTest('a class the sheet does not compile is refused, with the line it is read on', async () => {
|
|
148
|
+
const findings = await check('export const Page = () => <h1 class={styles.missing} />;');
|
|
149
|
+
expect(findings).toHaveLength(1);
|
|
150
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
151
|
+
expect(findings[0]?.at).toBe('apps/web/site/page.tsx:2');
|
|
152
|
+
expect(findings[0]?.cause).toContain(
|
|
153
|
+
'apps/web/site/page.module.scss compiles no class \`missing\`',
|
|
154
|
+
);
|
|
155
|
+
expect(findings[0]?.fix).toStartWith('add \`.missing { … }\` to apps/web/site/page.module.scss');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
unitTest('a near miss is repaired with the class the sheet does declare', async () => {
|
|
159
|
+
const dot = await check('export const Page = () => <h1 class={styles.heroo} />;');
|
|
160
|
+
expect(dot[0]?.fix).toStartWith('styles.hero — at ');
|
|
161
|
+
const bracket = await check("export const Page = () => <h1 class={styles['heroTitle']} />;");
|
|
162
|
+
expect(bracket[0]?.fix).toStartWith("styles['hero-title'] — at ");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
unitTest('a bracket read, a classList key and a second binding are all reads', async () => {
|
|
166
|
+
expect(await check("export const Page = () => <h1 class={styles['nope']} />;")).toHaveLength(1);
|
|
167
|
+
expect(
|
|
168
|
+
await check('export const Page = () => <h1 classList={{ [styles.nope]: on() }} />;'),
|
|
169
|
+
).toHaveLength(1);
|
|
170
|
+
const two =
|
|
171
|
+
"import styles from './page.module.scss';\\nimport shell from '../shared/shell.module.scss';";
|
|
172
|
+
const findings = await check('export const Page = () => <h1 class={shell.hero} />;', two);
|
|
173
|
+
expect(findings).toHaveLength(1);
|
|
174
|
+
expect(findings[0]?.cause).toContain('apps/web/shared/shell.module.scss');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
unitTest('one missing class read five times is one finding', async () => {
|
|
178
|
+
const body = 'const a = [styles.gone, styles.gone, styles.gone, styles.gone, styles.gone];';
|
|
179
|
+
expect(await check(body)).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// The legitimate lookalikes — the reason the rule can stay switched on.
|
|
183
|
+
unitTest('a compiled class, however it is read, is silent', async () => {
|
|
184
|
+
const silent = [
|
|
185
|
+
'export const Page = () => <h1 class={styles.hero} />;',
|
|
186
|
+
"export const Page = () => <h1 class={styles['hero-title']} />;",
|
|
187
|
+
'export const Page = () => <h1 classList={{ [styles.hero]: on() }} />;',
|
|
188
|
+
// Generated by a mixin: only the compiled sheet knows it exists.
|
|
189
|
+
"export const Page = () => <h1 class={styles['tone-accent']} />;",
|
|
190
|
+
];
|
|
191
|
+
for (const body of silent) expect(await check(body)).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
unitTest('a computed key, a comment and another object are out of scope', async () => {
|
|
195
|
+
const silent = [
|
|
196
|
+
'export const Page = (p) => <h1 class={styles[p.tone]} />;',
|
|
197
|
+
\`export const Page = (p) => <h1 class={styles[\\\`tone-\\\${p.tone}\\\`]} />;\`,
|
|
198
|
+
'// styles.missing\\nexport const Page = () => <h1 class={styles.hero} />;',
|
|
199
|
+
'export const Page = (p) => <h1 class={p.styles.missing} />;',
|
|
200
|
+
'export const Page = () => <h1 class={mystyles.missing} />;',
|
|
201
|
+
];
|
|
202
|
+
for (const body of silent) expect(await check(body)).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
unitTest("a sheet that does not compile is the build's finding, not this guard's", async () => {
|
|
206
|
+
const imports = "import styles from './broken.module.scss';";
|
|
207
|
+
expect(await check('export const Page = () => <h1 class={styles.any} />;', imports)).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
/** `guards/undefined-style-class.ts` and its test. No index, no registry — the directory is the registration. */
|
|
212
|
+
export const undefinedStyleClassGuardFiles = (): readonly GeneratedFile[] => [
|
|
213
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
214
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
215
|
+
];
|
|
@@ -104,7 +104,7 @@ export function untranslatedStrings(files: readonly SourceFile[]): readonly Find
|
|
|
104
104
|
|
|
105
105
|
export const guard: Guard = {
|
|
106
106
|
summary: 'a rendered string comes from t(), never typed into the page',
|
|
107
|
-
async check(
|
|
107
|
+
async check(_root, sources) {
|
|
108
108
|
const files: SourceFile[] = [];
|
|
109
109
|
// Every rendered surface: an app's \`site/\` and \`app/\`, and the shared components under
|
|
110
110
|
// \`packages/*/src\` — \`x new\` scaffolds a \`packages/ui\` whose components render to a user, so
|
|
@@ -115,13 +115,13 @@ export const guard: Guard = {
|
|
|
115
115
|
// 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
|
|
116
116
|
// \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
|
|
117
117
|
// matches 17 — so folding these into one line silently turns the guard off, which is worse than
|
|
118
|
-
// the hole it closes. A LEADING group is fine and
|
|
118
|
+
// the hole it closes. A LEADING group is fine and the stylesheet guards here rely on it:
|
|
119
119
|
// \`{apps,packages}/**/*.scss\` matches all 15.
|
|
120
120
|
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
files.push({ path, source:
|
|
121
|
+
// The run's ONE read: every guard asking for these two globs shares the walk.
|
|
122
|
+
for (const file of await sources.files(pattern)) {
|
|
123
|
+
if (/\\.test\\.tsx?$/.test(file.path)) continue;
|
|
124
|
+
files.push({ path: file.path, source: file.text });
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
return untranslatedStrings(files);
|
|
@@ -94,19 +94,15 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
94
94
|
|
|
95
95
|
export const guard: Guard = {
|
|
96
96
|
summary: 'a date is never formatted without an explicit IANA time zone',
|
|
97
|
-
async check(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
|
|
107
|
-
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
108
|
-
}
|
|
109
|
-
return unzonedDates(files);
|
|
97
|
+
async check(_root, sources) {
|
|
98
|
+
// A test's subject is often the wrong shape on purpose; excluding it hides no rendered date
|
|
99
|
+
// from a user. The run's ONE read of the source: every guard asking for this glob shares it.
|
|
100
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
101
|
+
return unzonedDates(
|
|
102
|
+
code
|
|
103
|
+
.filter((file) => !/\\.(?:test|d)\\.tsx?$/.test(file.path))
|
|
104
|
+
.map((file) => ({ path: file.path, source: file.text })),
|
|
105
|
+
);
|
|
110
106
|
},
|
|
111
107
|
};
|
|
112
108
|
`;
|
package/src/templates/index.ts
CHANGED
|
@@ -43,8 +43,9 @@ export { dashboardFiles } from './scaffold-dashboard';
|
|
|
43
43
|
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
44
44
|
export { entryFiles } from './scaffold-entries';
|
|
45
45
|
export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
|
|
46
|
-
// The
|
|
47
|
-
|
|
46
|
+
// The guards `x new` ships — `SHIPPED_GUARD_NAMES` is the list — distinct from `guardFiles` above,
|
|
47
|
+
// which is the blank template `x g guard <name>` writes for a name that is not one of them.
|
|
48
|
+
export { SHIPPED_GUARD_NAMES, scaffoldGuardFiles, shippedGuardFiles } from './scaffold-guards';
|
|
48
49
|
export { i18nIndex, localeEntry, localeImport } from './scaffold-i18n';
|
|
49
50
|
export { repoFiles } from './scaffold-repo';
|
|
50
51
|
export { shellFiles } from './scaffold-shell';
|
package/src/templates/island.ts
CHANGED
|
@@ -73,8 +73,8 @@ function ${Name}(props: ${Name}Props): JSX.Element {
|
|
|
73
73
|
*/
|
|
74
74
|
export function mount(el: HTMLElement, props: ${Name}Props): () => void {
|
|
75
75
|
el.textContent = '';
|
|
76
|
-
// Solid's \`render\` answers its disposer; returning it is what lets \`
|
|
77
|
-
//
|
|
76
|
+
// Solid's \`render\` answers its disposer; returning it is what lets \`@ultimat3/testing\`
|
|
77
|
+
// stop this island — its timers included — when a test is done with it.
|
|
78
78
|
return render(() => <${Name} {...props} />, el);
|
|
79
79
|
}
|
|
80
80
|
`;
|
|
@@ -102,9 +102,9 @@ const islandStyle =
|
|
|
102
102
|
const islandTest = (
|
|
103
103
|
name: string,
|
|
104
104
|
dir: string,
|
|
105
|
-
): string => `// The island the browser actually runs
|
|
106
|
-
// \`buildIslands\` that \`x build\` and \`x dev\` use,
|
|
107
|
-
// runtime
|
|
105
|
+
): string => `// The island the browser actually runs, in every state its manifest declares: built once for
|
|
106
|
+
// this file with the same \`buildIslands\` that \`x build\` and \`x dev\` use, imported the way the
|
|
107
|
+
// hydration runtime imports it, and driven against a DOM small enough to read.
|
|
108
108
|
//
|
|
109
109
|
// Importing the module and asserting \`typeof mount === 'function'\` proves the file exists, and a
|
|
110
110
|
// file that exists is exactly what ships dead: a renamed export, a dropped handler and a signal
|
|
@@ -112,60 +112,43 @@ const islandTest = (
|
|
|
112
112
|
|
|
113
113
|
import { join } from 'node:path';
|
|
114
114
|
import { buildIslands } from '@ultimat3/cli';
|
|
115
|
-
import {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
let mounted: MountedIsland;
|
|
129
|
-
|
|
130
|
-
// The build is a Babel pass plus a browser bundle — seconds, not milliseconds. It lives in
|
|
131
|
-
// \`beforeAll\` with its own timeout because \`test\` takes no third argument: fixtures are resolved
|
|
132
|
-
// per case, so the slow work goes where it can be given one and every case shares the result.
|
|
133
|
-
beforeAll(async () => {
|
|
134
|
-
mounted = await mountIsland({
|
|
135
|
-
build: buildIslands,
|
|
136
|
-
root: APP_ROOT,
|
|
137
|
-
file: ISLAND,
|
|
138
|
-
props: { label: 'Open' },
|
|
139
|
-
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
140
|
-
shell: '<span>Open</span>',
|
|
141
|
-
});
|
|
142
|
-
}, 60_000);
|
|
143
|
-
|
|
144
|
-
// The fake \`document\` is process-global: left installed it reaches every LATER FILE in the run.
|
|
145
|
-
//
|
|
146
|
-
// \`?.\` on a binding the type says is always set: TypeScript's definite-assignment analysis does not
|
|
147
|
-
// cross the \`beforeAll\` closure, so a setup that REJECTED leaves this undefined at run time — and
|
|
148
|
-
// bun runs \`afterAll\` regardless. Unguarded, the build failure is followed by a \`TypeError:
|
|
149
|
-
// undefined is not an object\` that says nothing, and that second line is the one a tail reads.
|
|
150
|
-
// Nothing is skipped by the guard: \`mountIsland\` restores the process itself when a mount throws.
|
|
151
|
-
afterAll(() => {
|
|
152
|
-
mounted?.[Symbol.dispose]();
|
|
153
|
-
});
|
|
115
|
+
import { describeIslandState, expect, test } from '@ultimat3/testing';
|
|
116
|
+
import { ${camel(name)}States } from './${name}.island.states';
|
|
117
|
+
|
|
118
|
+
// One block per declared state: \`describeIslandState\` mounts it before the block's first test
|
|
119
|
+
// and disposes it after the last. The props are the manifest's own — what
|
|
120
|
+
// \`x shot --island ${name}\` photographs — so the picture and the test are of one component.
|
|
121
|
+
const island = {
|
|
122
|
+
build: buildIslands,
|
|
123
|
+
root: join(import.meta.dir, ${upToAppRoot(dir)}),
|
|
124
|
+
// What the server rendered inside the island's wrapper. \`mount\` replaces it.
|
|
125
|
+
shell: '<span>shell</span>',
|
|
126
|
+
};
|
|
154
127
|
|
|
155
|
-
|
|
156
|
-
test('mount replaces the server shell', () => {
|
|
157
|
-
expect(mounted.find('span')).toBeNull();
|
|
128
|
+
describeIslandState(${camel(name)}States, 'idle', island, (mounted) => {
|
|
129
|
+
test('mount replaces the server shell with the labelled control', () => {
|
|
130
|
+
expect(mounted().find('span')).toBeNull();
|
|
131
|
+
expect(mounted().text('button')).toBe('Open');
|
|
158
132
|
// Solid compiles to real DOM calls; a chunk that fell back to the classic React factory names
|
|
159
133
|
// a global that is not in it, and \`Bun.build\` answers \`success: true\` over that all the same.
|
|
160
|
-
expect(mounted.code).not.toMatch(/\\bReact\\b/);
|
|
134
|
+
expect(mounted().code).not.toMatch(/\\bReact\\b/);
|
|
161
135
|
});
|
|
162
136
|
|
|
163
137
|
test('a click reaches the DOM through the signal', () => {
|
|
164
|
-
expect(mounted.text('[data-role="count"]')).toBe('0');
|
|
138
|
+
expect(mounted().text('[data-role="count"]')).toBe('0');
|
|
165
139
|
// \`false\` means no handler ran — an island whose onClick never reached the DOM looks identical
|
|
166
140
|
// to a selector typo otherwise.
|
|
167
|
-
expect(mounted.fire('button', 'click')).toBe(true);
|
|
168
|
-
expect(mounted.text('[data-role="count"]')).toBe('1');
|
|
141
|
+
expect(mounted().fire('button', 'click')).toBe(true);
|
|
142
|
+
expect(mounted().text('[data-role="count"]')).toBe('1');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describeIslandState(${camel(name)}States, 'long-label', island, (mounted) => {
|
|
147
|
+
test('the label arrives whole, and the control still counts from zero', () => {
|
|
148
|
+
// Whether it FITS is a picture's question — \`x shot --island ${name} --state long-label\`.
|
|
149
|
+
// What a test can say is that the island was handed this state's own text, not the idle one's.
|
|
150
|
+
expect(mounted().text('button')).toBe('Abrechnungseinstellungen anzeigen');
|
|
151
|
+
expect(mounted().text('[data-role="count"]')).toBe('0');
|
|
169
152
|
});
|
|
170
153
|
});
|
|
171
154
|
`;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The UNIT tests `x g job` / `x g task` write beside the `.job.test.ts`: the body, run by an
|
|
2
|
+
// in-process worker (`runJobs`) against the in-memory queue and the in-memory driver. Split from
|
|
3
|
+
// `job.ts`, which owns the declarations and the job-suite tests — an app's coverage floor counts
|
|
4
|
+
// the unit suite alone, so a body only the `job` step runs is uncovered source.
|
|
5
|
+
|
|
6
|
+
import { sortedImports } from './imports';
|
|
7
|
+
import type { NameSet } from './naming';
|
|
8
|
+
|
|
9
|
+
const ID = '00000000-0000-4000-8000-000000000001';
|
|
10
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
11
|
+
|
|
12
|
+
/** The steps one run of the tenant-scoped body may take, and which test proves each. */
|
|
13
|
+
export interface JobBodyShape {
|
|
14
|
+
/** The body reads `../repo` under the org its input names. */
|
|
15
|
+
readonly scoped: boolean;
|
|
16
|
+
/** The slice still takes the row `x g entity` scaffolds, so a test may store one. */
|
|
17
|
+
readonly storesRow: boolean;
|
|
18
|
+
readonly dbModule: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HEADER = (
|
|
22
|
+
name: NameSet,
|
|
23
|
+
): string => `// ${name.camel}'s BODY, run by a worker in this process: \`runJobs\` enqueues, claims and executes
|
|
24
|
+
// against an in-memory queue, so what this pins is which steps one run takes and what the body
|
|
25
|
+
// answers. The durable guarantees — the idempotency key, the dedupe, the retry policy — are the
|
|
26
|
+
// job suite's, next door.`;
|
|
27
|
+
|
|
28
|
+
const storedRowTest = (name: NameSet): string => `
|
|
29
|
+
unitTest('a stored row is loaded, then processed, once each', async ({ runJobs }) => {
|
|
30
|
+
const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
|
|
31
|
+
const row = await runWithContext(createContext({ actor: member }), () => repo.insert(draft));
|
|
32
|
+
const trace = await runJobs(${name.camel}, { id: row.id, orgId });
|
|
33
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
34
|
+
expect(trace.executions[0]?.result).toEqual({ skipped: false });
|
|
35
|
+
expect(trace.steps.load?.executions).toBe(1);
|
|
36
|
+
expect(trace.steps.process?.executions).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const scopedBodyTest = (name: NameSet, shape: JobBodyShape): string => `${HEADER(name)}
|
|
41
|
+
${sortedImports([
|
|
42
|
+
...(shape.storesRow
|
|
43
|
+
? [
|
|
44
|
+
"import { createContext, runWithContext } from '@ultimat3/core';",
|
|
45
|
+
"import { testActor } from '@ultimat3/policy';",
|
|
46
|
+
"import { afterEach, expect, unitTest } from '@ultimat3/testing';",
|
|
47
|
+
`import { driver } from '${shape.dbModule}';`,
|
|
48
|
+
]
|
|
49
|
+
: ["import { expect, unitTest } from '@ultimat3/testing';"]),
|
|
50
|
+
])}${shape.storesRow ? "\nimport * as repo from '../repo';" : ''}
|
|
51
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
52
|
+
|
|
53
|
+
const id = '${ID}';
|
|
54
|
+
const orgId = '${ORG}';
|
|
55
|
+
${
|
|
56
|
+
shape.storesRow
|
|
57
|
+
? `
|
|
58
|
+
// Who stores the row the job then reads: the body itself runs as the org its input names.
|
|
59
|
+
const member = testActor('member', { orgId }).actor;
|
|
60
|
+
|
|
61
|
+
// One store per process: without this, one test's rows are the next test's fixtures.
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
driver.reset?.();
|
|
64
|
+
});
|
|
65
|
+
`
|
|
66
|
+
: ''
|
|
67
|
+
}
|
|
68
|
+
unitTest('an id nothing holds stops after the load: nothing is processed', async ({ runJobs }) => {
|
|
69
|
+
// The refusal branch first. \`completed\`, not failed: a row deleted between the enqueue and
|
|
70
|
+
// the run is not an error to retry five times — and the body says which of the two it was.
|
|
71
|
+
const trace = await runJobs(${name.camel}, { id, orgId });
|
|
72
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
73
|
+
expect(trace.executions[0]?.result).toEqual({ skipped: true });
|
|
74
|
+
expect(Object.keys(trace.steps)).toEqual(['load']);
|
|
75
|
+
});
|
|
76
|
+
${shape.storesRow ? storedRowTest(name) : ''}`;
|
|
77
|
+
|
|
78
|
+
const neutralBodyTest = (name: NameSet): string => `${HEADER(name)}
|
|
79
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
80
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
81
|
+
|
|
82
|
+
const id = '${ID}';
|
|
83
|
+
|
|
84
|
+
unitTest('one run takes its one step, once, and completes', async ({ runJobs }) => {
|
|
85
|
+
const trace = await runJobs(${name.camel}, { id });
|
|
86
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
87
|
+
expect(trace.executions[0]?.result).toEqual({ processed: true });
|
|
88
|
+
expect(Object.keys(trace.steps)).toEqual(['process']);
|
|
89
|
+
expect(trace.steps.process?.executions).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/** `<name>.test.ts` for a job: its body, in the shape the slice earns. */
|
|
94
|
+
export const jobBodyTest = (name: NameSet, shape: JobBodyShape): string =>
|
|
95
|
+
shape.scoped ? scopedBodyTest(name, shape) : neutralBodyTest(name);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `<name>.test.ts` for a task: firing it, which is the one thing its `enqueue` function is for.
|
|
99
|
+
* The payload it declares is run to completion by the same worker, so a payload the job's own
|
|
100
|
+
* input schema refuses fails here rather than at 03:00.
|
|
101
|
+
*/
|
|
102
|
+
export const taskBodyTest = (
|
|
103
|
+
name: NameSet,
|
|
104
|
+
jobName: NameSet,
|
|
105
|
+
): string => `// ${name.camel}, fired: the job it queues and the payload it queues it with, run to completion by a
|
|
106
|
+
// worker in this process. The schedule — the cron and its time zone — is the job suite's, next door.
|
|
107
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
108
|
+
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
109
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
110
|
+
|
|
111
|
+
unitTest('firing it queues its one job, and that payload completes', async ({ runJobs }) => {
|
|
112
|
+
const queued = await ${name.camel}.enqueue();
|
|
113
|
+
const jobs = queued.map((result) => result.job);
|
|
114
|
+
expect(jobs).toEqual([${jobName.camel}.name]);
|
|
115
|
+
expect(await runJobs.depth(${jobName.camel})).toBe(1);
|
|
116
|
+
// A payload the job's input schema refuses fails the run, here rather than on the schedule.
|
|
117
|
+
const trace = await runJobs.drain();
|
|
118
|
+
expect(trace.executions.map((run) => run.outcome)).toEqual(['completed']);
|
|
119
|
+
expect(await runJobs.depth(${jobName.camel})).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
`;
|
package/src/templates/job.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { stripComments } from '@ultimat3/core';
|
|
7
7
|
import type { FeatureTarget } from './entity';
|
|
8
|
+
import { jobBodyTest, taskBodyTest } from './job-body-test';
|
|
8
9
|
import type { GeneratedFile, NameSet } from './naming';
|
|
9
10
|
import { names } from './naming';
|
|
10
|
-
import {
|
|
11
|
+
import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
|
|
12
|
+
import { sliceExports, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
|
|
11
13
|
import { wrapList } from './wrap';
|
|
12
14
|
|
|
13
15
|
const jobSource = (
|
|
@@ -34,7 +36,7 @@ export const ${name.camel} = job({
|
|
|
34
36
|
const row = await step.run('load', () => repo.byId(input.id));
|
|
35
37
|
if (row === undefined) return { skipped: true };
|
|
36
38
|
await step.run('process', async () => {
|
|
37
|
-
await repo.
|
|
39
|
+
await repo.list(1);
|
|
38
40
|
});
|
|
39
41
|
return { skipped: false };
|
|
40
42
|
},
|
|
@@ -43,7 +45,7 @@ export const ${name.camel} = job({
|
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* The other shape: this feature's own `entity.ts` names no tenant column (or names `'none'`), or
|
|
46
|
-
* its `repo.ts` does not export the `byId`/`
|
|
48
|
+
* its `repo.ts` does not export the `byId`/`list` pair the tenant-scoped body above calls —
|
|
47
49
|
* checked by `isTenantScopedSlice` against what is actually on the app's disk, never assumed. The
|
|
48
50
|
* body imports neither `../entity` nor `../repo`, so it compiles whether this feature has an
|
|
49
51
|
* entity yet or has one with no tenant, and `tenant: 'none'` is stated rather than defaulted so a
|
|
@@ -63,7 +65,7 @@ export const ${name.camel} = job({
|
|
|
63
65
|
// entity names none. \`tenant: 'none'\` STRIPS the org from the run rather than leaving one
|
|
64
66
|
// behind, so a tenant-scoped read added later fails closed with X_TENANCY_ACTOR_ORG_REQUIRED
|
|
65
67
|
// instead of reading whichever org the enqueuer happened to hold. Once this feature's entity
|
|
66
|
-
// declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`
|
|
68
|
+
// declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`list\`, the next \`x g job\` in
|
|
67
69
|
// this slice scaffolds the tenant-scoped shape above instead.
|
|
68
70
|
tenant: 'none',
|
|
69
71
|
idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
|
|
@@ -283,7 +285,7 @@ export interface JobOptions extends FeatureTarget {
|
|
|
283
285
|
* This feature's own `entity.ts` as it stands on disk, or absent when the feature has none yet.
|
|
284
286
|
* Supplied by `run` for the same reason `ActionOptions.sliceErrors` is: whether the slice is
|
|
285
287
|
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
286
|
-
* `repo.byId`/`repo.
|
|
288
|
+
* `repo.byId`/`repo.list` calls into a feature whose repo never declared them —
|
|
287
289
|
* `x g task purgeOrphans --feature links` on a `links` slice with no `orgId` produced files that
|
|
288
290
|
* did not compile.
|
|
289
291
|
*/
|
|
@@ -297,9 +299,9 @@ export interface JobOptions extends FeatureTarget {
|
|
|
297
299
|
* NOT: only `x g entity` and `x g resource` create a feature's data, and counting absent as scoped
|
|
298
300
|
* made `x g task nightly` lay down an `entity('nightlies', { title, price })` that the `drift` step
|
|
299
301
|
* then demanded a migration for. One that exists is trusted: it declares
|
|
300
|
-
* a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`
|
|
302
|
+
* a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`list` pair the
|
|
301
303
|
* tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
|
|
302
|
-
* an author trimmed `
|
|
304
|
+
* an author trimmed `list` out of `repo.ts` (or never generated one) is not a slice this job
|
|
303
305
|
* can read through either.
|
|
304
306
|
*/
|
|
305
307
|
export function isTenantScopedSlice(
|
|
@@ -310,7 +312,7 @@ export function isTenantScopedSlice(
|
|
|
310
312
|
const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
|
|
311
313
|
if (!declaresTenant) return false;
|
|
312
314
|
if (sliceRepo === undefined) return true;
|
|
313
|
-
return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, '
|
|
315
|
+
return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, 'list');
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
export function jobFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
|
|
@@ -335,6 +337,16 @@ export function jobFiles(rawName: string, target: JobOptions): readonly Generate
|
|
|
335
337
|
path: `${dir}/${name.kebab}.job.test.ts`,
|
|
336
338
|
contents: scoped ? jobTest(name) : neutralJobTest(name),
|
|
337
339
|
},
|
|
340
|
+
// And the plain `<name>.test.ts`: the BODY, on the `unit` step. The suite above pins what a
|
|
341
|
+
// queue guarantees; an app's coverage floor counts the unit suite alone (`job-body-test.ts`).
|
|
342
|
+
{
|
|
343
|
+
path: `${dir}/${name.kebab}.test.ts`,
|
|
344
|
+
contents: jobBodyTest(name, {
|
|
345
|
+
scoped,
|
|
346
|
+
storesRow: scoped && sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
|
|
347
|
+
dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
|
|
348
|
+
}),
|
|
349
|
+
},
|
|
338
350
|
];
|
|
339
351
|
}
|
|
340
352
|
|
|
@@ -350,6 +362,9 @@ export function taskFiles(rawName: string, target: JobOptions): readonly Generat
|
|
|
350
362
|
},
|
|
351
363
|
// A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
|
|
352
364
|
{ path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
|
|
365
|
+
// The task FIRED, on the `unit` step: its `enqueue` function is otherwise run by nothing the
|
|
366
|
+
// coverage floor counts.
|
|
367
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: taskBodyTest(name, jobName) },
|
|
353
368
|
...jobFiles(`${rawName}-job`, target),
|
|
354
369
|
];
|
|
355
370
|
}
|