@ulysses-ai/create-workspace 0.15.0-beta.0 → 0.15.0-beta.2

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 (26) hide show
  1. package/lib/init.mjs +12 -25
  2. package/lib/scaffold.mjs +3 -2
  3. package/package.json +1 -1
  4. package/template/.claude/rules/memory-guidance.md +30 -0
  5. package/template/.claude/scripts/build-workspace-context.mjs +370 -23
  6. package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
  7. package/template/.claude/skills/complete-work/SKILL.md +88 -0
  8. package/template/.claude/skills/maintenance/SKILL.md +79 -11
  9. package/template/.claude/skills/release/SKILL.md +3 -0
  10. package/template/.claude/skills/workspace-update/SKILL.md +7 -1
  11. package/template/workspace.json.tmpl +1 -0
  12. package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
  13. package/template/.claude/hooks/_utils.test.mjs +0 -99
  14. package/template/.claude/lib/freshness.test.mjs +0 -175
  15. package/template/.claude/lib/registry-check.test.mjs +0 -130
  16. package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
  17. package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
  18. package/template/.claude/scripts/capture-context.test.mjs +0 -383
  19. package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
  20. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
  21. package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
  22. package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
  23. package/template/.claude/scripts/sweep-references.test.mjs +0 -184
  24. package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
  25. package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
  26. package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
@@ -1,383 +0,0 @@
1
- #!/usr/bin/env node
2
- // Unit tests for capture-context.mjs
3
- // Run: node template/.claude/scripts/capture-context.test.mjs
4
-
5
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { join } from 'node:path';
8
- import { spawnSync } from 'node:child_process';
9
- import {
10
- parseArgs,
11
- validate,
12
- computeDir,
13
- computeBaseFilename,
14
- resolveCollision,
15
- buildFrontmatter,
16
- renderFrontmatter,
17
- plan,
18
- write,
19
- } from './capture-context.mjs';
20
-
21
- let failed = 0;
22
- let passed = 0;
23
-
24
- function assert(cond, msg) {
25
- if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
26
- }
27
-
28
- function assertEq(actual, expected, msg) {
29
- const a = JSON.stringify(actual);
30
- const e = JSON.stringify(expected);
31
- if (a === e) { passed++; } else {
32
- failed++;
33
- console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
34
- }
35
- }
36
-
37
- function assertThrows(fn, matcher, msg) {
38
- try {
39
- fn();
40
- failed++;
41
- console.error(` FAIL: ${msg} (expected throw)`);
42
- } catch (e) {
43
- if (matcher.test(e.message)) { passed++; }
44
- else { failed++; console.error(` FAIL: ${msg}\n error: ${e.message}`); }
45
- }
46
- }
47
-
48
- function setupRoot() {
49
- return mkdtempSync(join(tmpdir(), 'cap-test-'));
50
- }
51
-
52
- function cleanup(root) {
53
- rmSync(root, { recursive: true, force: true });
54
- }
55
-
56
- console.log('# validate');
57
-
58
- {
59
- assertThrows(
60
- () => validate({ type: null, topic: 'x', scope: 'shared' }),
61
- /--type must be one of/,
62
- 'rejects missing type',
63
- );
64
- }
65
-
66
- {
67
- assertThrows(
68
- () => validate({ type: 'memo', topic: 'x', scope: 'shared' }),
69
- /--type must be one of/,
70
- 'rejects invalid type',
71
- );
72
- }
73
-
74
- {
75
- assertThrows(
76
- () => validate({ type: 'braindump', topic: 'BadCase', scope: 'shared' }),
77
- /kebab-case/,
78
- 'rejects non-kebab-case topic',
79
- );
80
- }
81
-
82
- {
83
- assertThrows(
84
- () => validate({ type: 'braindump', topic: 'x', scope: 'invalid' }),
85
- /--scope must be one of/,
86
- 'rejects invalid scope',
87
- );
88
- }
89
-
90
- {
91
- assertThrows(
92
- () => validate({ type: 'braindump', topic: 'x', scope: 'team-member' }),
93
- /--user is required/,
94
- 'rejects team-member scope without user',
95
- );
96
- }
97
-
98
- {
99
- assertThrows(
100
- () => validate({ type: 'braindump', topic: 'x', scope: 'team-member', user: 'bad/name' }),
101
- /--user must be alphanumeric/,
102
- 'rejects user with slash',
103
- );
104
- }
105
-
106
- {
107
- // valid input doesn't throw
108
- validate({ type: 'braindump', topic: 'my-topic', scope: 'shared' });
109
- validate({ type: 'handoff', topic: 'my-topic', scope: 'team-member', user: 'alice' });
110
- passed += 2;
111
- }
112
-
113
- console.log('# computeDir');
114
-
115
- {
116
- const sharedDir = computeDir({ scope: 'shared', root: '/ws' });
117
- assertEq(sharedDir, '/ws/workspace-context/shared', 'shared scope path');
118
-
119
- const userDir = computeDir({ scope: 'team-member', user: 'alice', root: '/ws' });
120
- assertEq(userDir, '/ws/workspace-context/team-member/alice', 'team-member scope path');
121
- }
122
-
123
- console.log('# computeBaseFilename');
124
-
125
- {
126
- assertEq(
127
- computeBaseFilename({ type: 'braindump', topic: 'my-thing', localOnly: false }),
128
- 'braindump_my-thing.md',
129
- 'standard naming',
130
- );
131
- assertEq(
132
- computeBaseFilename({ type: 'research', topic: 'my-thing', localOnly: true }),
133
- 'local-only-research_my-thing.md',
134
- 'local-only prefix applied',
135
- );
136
- assertEq(
137
- computeBaseFilename({ type: 'handoff', topic: 'workstream-x', localOnly: false }),
138
- 'handoff_workstream-x.md',
139
- 'handoff prefix',
140
- );
141
- }
142
-
143
- console.log('# resolveCollision');
144
-
145
- {
146
- const root = setupRoot();
147
- const path1 = resolveCollision(root, 'braindump_x.md', false);
148
- assertEq(path1, join(root, 'braindump_x.md'), 'no collision returns base');
149
- cleanup(root);
150
- }
151
-
152
- {
153
- const root = setupRoot();
154
- writeFileSync(join(root, 'braindump_x.md'), 'first');
155
- const path2 = resolveCollision(root, 'braindump_x.md', false);
156
- assertEq(path2, join(root, 'braindump_x-2.md'), 'first collision → -2');
157
- writeFileSync(path2, 'second');
158
- const path3 = resolveCollision(root, 'braindump_x.md', false);
159
- assertEq(path3, join(root, 'braindump_x-3.md'), 'second collision → -3');
160
- cleanup(root);
161
- }
162
-
163
- {
164
- const root = setupRoot();
165
- writeFileSync(join(root, 'braindump_x.md'), 'first');
166
- const path = resolveCollision(root, 'braindump_x.md', true);
167
- assertEq(path, join(root, 'braindump_x.md'), '--update returns base even when exists');
168
- cleanup(root);
169
- }
170
-
171
- console.log('# buildFrontmatter');
172
-
173
- {
174
- const fm = buildFrontmatter({
175
- type: 'braindump',
176
- topic: 'my-thing',
177
- scope: 'shared',
178
- });
179
- assertEq(fm.state, 'ephemeral', 'state ephemeral');
180
- assertEq(fm.lifecycle, 'active', 'lifecycle active');
181
- assertEq(fm.type, 'braindump', 'type set');
182
- assertEq(fm.topic, 'my-thing', 'topic set');
183
- assert(!('author' in fm), 'no author for shared scope');
184
- assert(/^\d{4}-\d{2}-\d{2}$/.test(fm.updated), 'updated is ISO date');
185
- }
186
-
187
- {
188
- const fm = buildFrontmatter({
189
- type: 'research',
190
- topic: 'idea',
191
- scope: 'team-member',
192
- user: 'alice',
193
- variant: 'aside',
194
- description: 'A neat idea.',
195
- });
196
- assertEq(fm.author, 'alice', 'author from --user');
197
- assertEq(fm.variant, 'aside', 'variant included');
198
- assertEq(fm.description, 'A neat idea.', 'description included');
199
- }
200
-
201
- console.log('# renderFrontmatter');
202
-
203
- {
204
- const out = renderFrontmatter({
205
- state: 'ephemeral',
206
- type: 'braindump',
207
- topic: 'x',
208
- });
209
- assertEq(
210
- out,
211
- '---\nstate: ephemeral\ntype: braindump\ntopic: x\n---',
212
- 'frontmatter rendered with --- fences',
213
- );
214
- }
215
-
216
- console.log('# plan');
217
-
218
- {
219
- const root = setupRoot();
220
- const planned = plan({
221
- type: 'braindump',
222
- topic: 'my-topic',
223
- scope: 'team-member',
224
- user: 'alice',
225
- root,
226
- localOnly: false,
227
- update: false,
228
- });
229
- assertEq(
230
- planned.filePath,
231
- join(root, 'workspace-context', 'team-member', 'alice', 'braindump_my-topic.md'),
232
- 'planned path includes type prefix and user dir',
233
- );
234
- cleanup(root);
235
- }
236
-
237
- {
238
- const root = setupRoot();
239
- const args = {
240
- type: 'research',
241
- topic: 'idea',
242
- scope: 'team-member',
243
- user: 'alice',
244
- root,
245
- localOnly: true,
246
- update: false,
247
- variant: 'aside',
248
- };
249
- const planned = plan(args);
250
- assertEq(
251
- planned.filePath,
252
- join(root, 'workspace-context', 'team-member', 'alice', 'local-only-research_idea.md'),
253
- 'local-only flag prefixes filename',
254
- );
255
- cleanup(root);
256
- }
257
-
258
- console.log('# write end-to-end');
259
-
260
- {
261
- const root = setupRoot();
262
- const filePath = write(
263
- {
264
- type: 'braindump',
265
- topic: 'my-topic',
266
- scope: 'team-member',
267
- user: 'alice',
268
- description: 'Captured my topic.',
269
- root,
270
- localOnly: false,
271
- update: false,
272
- },
273
- '## Section\n\nBody text.\n',
274
- );
275
- assert(existsSync(filePath), 'file written');
276
- const content = readFileSync(filePath, 'utf-8');
277
- assert(content.startsWith('---\n'), 'starts with frontmatter');
278
- assert(content.includes('type: braindump'), 'frontmatter has type');
279
- assert(content.includes('topic: my-topic'), 'frontmatter has topic');
280
- assert(content.includes('author: alice'), 'frontmatter has author');
281
- assert(content.includes('description: Captured my topic.'), 'frontmatter has description');
282
- assert(content.includes('## Section\n\nBody text.'), 'body preserved');
283
- cleanup(root);
284
- }
285
-
286
- {
287
- // collision: second write becomes -2
288
- const root = setupRoot();
289
- const args = {
290
- type: 'braindump',
291
- topic: 'topic',
292
- scope: 'shared',
293
- root,
294
- localOnly: false,
295
- update: false,
296
- };
297
- const path1 = write(args, 'first body');
298
- const path2 = write(args, 'second body');
299
- assert(path1 !== path2, 'collision creates new path');
300
- assert(path2.endsWith('braindump_topic-2.md'), 'second is -2');
301
- cleanup(root);
302
- }
303
-
304
- {
305
- // --update: overwrites existing
306
- const root = setupRoot();
307
- const args = {
308
- type: 'braindump',
309
- topic: 'topic',
310
- scope: 'shared',
311
- root,
312
- localOnly: false,
313
- update: false,
314
- };
315
- const path1 = write(args, 'first body');
316
- const path2 = write({ ...args, update: true }, 'updated body');
317
- assertEq(path1, path2, '--update overwrites same path');
318
- const content = readFileSync(path1, 'utf-8');
319
- assert(content.includes('updated body'), 'content overwritten');
320
- assert(!content.includes('first body'), 'old content gone');
321
- cleanup(root);
322
- }
323
-
324
- console.log('# CLI end-to-end');
325
-
326
- {
327
- const root = setupRoot();
328
- const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
329
-
330
- const result = spawnSync(
331
- 'node',
332
- [scriptPath, '--type', 'braindump', '--topic', 'cli-test', '--scope', 'shared', '--root', root],
333
- { input: '## Body\n\nFrom stdin.\n', encoding: 'utf-8' },
334
- );
335
- assertEq(result.status, 0, 'CLI exits 0');
336
- const printedPath = result.stdout.trim();
337
- assert(printedPath.endsWith('braindump_cli-test.md'), 'CLI prints path with prefix');
338
- const content = readFileSync(printedPath, 'utf-8');
339
- assert(content.includes('From stdin.'), 'stdin body written');
340
- cleanup(root);
341
- }
342
-
343
- {
344
- // --print-only: no stdin needed, no file written
345
- const root = setupRoot();
346
- const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
347
- const result = spawnSync(
348
- 'node',
349
- [
350
- scriptPath,
351
- '--type', 'handoff',
352
- '--topic', 'no-write',
353
- '--scope', 'team-member',
354
- '--user', 'bob',
355
- '--root', root,
356
- '--print-only',
357
- ],
358
- { encoding: 'utf-8' },
359
- );
360
- assertEq(result.status, 0, '--print-only exits 0');
361
- const printedPath = result.stdout.trim();
362
- assert(printedPath.endsWith('handoff_no-write.md'), 'planned path printed');
363
- assert(!existsSync(printedPath), 'no file written in print-only mode');
364
- cleanup(root);
365
- }
366
-
367
- {
368
- // bad arg
369
- const root = setupRoot();
370
- const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
371
- const result = spawnSync(
372
- 'node',
373
- [scriptPath, '--type', 'invalid', '--topic', 'x', '--scope', 'shared', '--root', root],
374
- { input: 'body', encoding: 'utf-8' },
375
- );
376
- assertEq(result.status, 1, 'invalid type exits 1');
377
- assert(result.stderr.includes('--type must be one of'), 'error message piped to stderr');
378
- cleanup(root);
379
- }
380
-
381
- console.log('');
382
- console.log(`${passed} passed, ${failed} failed`);
383
- process.exit(failed > 0 ? 1 : 0);
@@ -1,184 +0,0 @@
1
- #!/usr/bin/env node
2
- // Unit tests for generate-claude-local.mjs
3
- // Run: node template/.claude/scripts/generate-claude-local.test.mjs
4
-
5
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { join } from 'node:path';
8
- import { spawnSync } from 'node:child_process';
9
- import {
10
- readWorkspaceUser,
11
- renderClaudeLocal,
12
- generateClaudeLocal,
13
- } from './generate-claude-local.mjs';
14
-
15
- let failed = 0;
16
- let passed = 0;
17
-
18
- function assert(cond, msg) {
19
- if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
20
- }
21
-
22
- function assertEq(actual, expected, msg) {
23
- const a = JSON.stringify(actual);
24
- const e = JSON.stringify(expected);
25
- if (a === e) { passed++; } else {
26
- failed++;
27
- console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
28
- }
29
- }
30
-
31
- function assertThrows(fn, matcher, msg) {
32
- try {
33
- fn();
34
- failed++;
35
- console.error(` FAIL: ${msg} (expected throw)`);
36
- } catch (e) {
37
- if (matcher.test(e.message)) { passed++; }
38
- else { failed++; console.error(` FAIL: ${msg}\n error: ${e.message}`); }
39
- }
40
- }
41
-
42
- function setupRoot(settings) {
43
- const root = mkdtempSync(join(tmpdir(), 'gcl-test-'));
44
- mkdirSync(join(root, '.claude'), { recursive: true });
45
- if (settings !== null) {
46
- writeFileSync(
47
- join(root, '.claude', 'settings.local.json'),
48
- JSON.stringify(settings),
49
- );
50
- }
51
- return root;
52
- }
53
-
54
- function cleanup(root) {
55
- rmSync(root, { recursive: true, force: true });
56
- }
57
-
58
- console.log('# readWorkspaceUser');
59
-
60
- {
61
- const root = setupRoot({ workspace: { user: 'alice' } });
62
- assertEq(readWorkspaceUser(root), 'alice', 'reads workspace.user');
63
- cleanup(root);
64
- }
65
-
66
- {
67
- const root = mkdtempSync(join(tmpdir(), 'gcl-test-'));
68
- assertThrows(
69
- () => readWorkspaceUser(root),
70
- /Missing.*settings\.local\.json/,
71
- 'missing settings file',
72
- );
73
- cleanup(root);
74
- }
75
-
76
- {
77
- const root = setupRoot({ workspace: {} });
78
- assertThrows(
79
- () => readWorkspaceUser(root),
80
- /workspace\.user not set/,
81
- 'missing user field',
82
- );
83
- cleanup(root);
84
- }
85
-
86
- {
87
- const root = setupRoot({ workspace: { user: 'bad/name' } });
88
- assertThrows(
89
- () => readWorkspaceUser(root),
90
- /must be alphanumeric/,
91
- 'rejects user with slash',
92
- );
93
- cleanup(root);
94
- }
95
-
96
- {
97
- const root = mkdtempSync(join(tmpdir(), 'gcl-test-'));
98
- mkdirSync(join(root, '.claude'), { recursive: true });
99
- writeFileSync(join(root, '.claude', 'settings.local.json'), 'not json');
100
- assertThrows(
101
- () => readWorkspaceUser(root),
102
- /Could not parse/,
103
- 'invalid JSON',
104
- );
105
- cleanup(root);
106
- }
107
-
108
- console.log('# renderClaudeLocal');
109
-
110
- {
111
- const out = renderClaudeLocal('alice');
112
- assert(out.includes('## My Context'), 'has heading');
113
- assert(out.includes('@workspace-context/team-member/alice/index.md'), 'has user-specific import');
114
- }
115
-
116
- console.log('# generateClaudeLocal');
117
-
118
- {
119
- const root = setupRoot({ workspace: { user: 'alice' } });
120
- const result = generateClaudeLocal(root);
121
- assertEq(result.status, 'written', 'first write reports written');
122
- const content = readFileSync(result.path, 'utf-8');
123
- assert(content.includes('alice/index.md'), 'file written with user import');
124
- cleanup(root);
125
- }
126
-
127
- {
128
- // running twice with same content reports unchanged
129
- const root = setupRoot({ workspace: { user: 'alice' } });
130
- generateClaudeLocal(root);
131
- const result = generateClaudeLocal(root);
132
- assertEq(result.status, 'unchanged', 'idempotent on second run');
133
- cleanup(root);
134
- }
135
-
136
- {
137
- // refuses to overwrite divergent content without --force
138
- const root = setupRoot({ workspace: { user: 'alice' } });
139
- writeFileSync(join(root, 'CLAUDE.local.md'), '## Custom\nUser had custom content here\n');
140
- assertThrows(
141
- () => generateClaudeLocal(root),
142
- /already exists with different content/,
143
- 'refuses to overwrite custom content',
144
- );
145
- cleanup(root);
146
- }
147
-
148
- {
149
- // --force overwrites
150
- const root = setupRoot({ workspace: { user: 'alice' } });
151
- writeFileSync(join(root, 'CLAUDE.local.md'), '## Custom\n');
152
- const result = generateClaudeLocal(root, { force: true });
153
- assertEq(result.status, 'written', '--force writes');
154
- const content = readFileSync(join(root, 'CLAUDE.local.md'), 'utf-8');
155
- assert(content.includes('alice/index.md'), 'content overwritten');
156
- cleanup(root);
157
- }
158
-
159
- console.log('# CLI end-to-end');
160
-
161
- {
162
- const root = setupRoot({ workspace: { user: 'bob' } });
163
- const scriptPath = new URL('./generate-claude-local.mjs', import.meta.url).pathname;
164
- const result = spawnSync('node', [scriptPath, '--root', root], { encoding: 'utf-8' });
165
- assertEq(result.status, 0, 'CLI exits 0');
166
- const out = JSON.parse(result.stdout);
167
- assertEq(out.status, 'written', 'CLI reports written');
168
- assert(existsSync(join(root, 'CLAUDE.local.md')), 'file created');
169
- cleanup(root);
170
- }
171
-
172
- {
173
- // CLI exits 1 on missing settings
174
- const root = mkdtempSync(join(tmpdir(), 'gcl-test-'));
175
- const scriptPath = new URL('./generate-claude-local.mjs', import.meta.url).pathname;
176
- const result = spawnSync('node', [scriptPath, '--root', root], { encoding: 'utf-8' });
177
- assertEq(result.status, 1, 'CLI exits 1 on missing settings');
178
- assert(result.stderr.includes('generate-claude-local'), 'stderr labeled');
179
- cleanup(root);
180
- }
181
-
182
- console.log('');
183
- console.log(`${passed} passed, ${failed} failed`);
184
- process.exit(failed > 0 ? 1 : 0);
@@ -1,54 +0,0 @@
1
- #!/usr/bin/env node
2
- // Unit tests for migrate-claude-md-freshness-include.mjs
3
- // Run: node template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs
4
- import { runMigration } from './migrate-claude-md-freshness-include.mjs';
5
- import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs';
6
- import { join } from 'path';
7
- import { tmpdir } from 'os';
8
-
9
- let failed = 0;
10
- let passed = 0;
11
- function assertEq(actual, expected, msg) {
12
- const a = JSON.stringify(actual);
13
- const e = JSON.stringify(expected);
14
- if (a === e) { passed++; } else {
15
- failed++;
16
- console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
17
- }
18
- }
19
-
20
- function withTemp(fn) {
21
- const dir = mkdtempSync(join(tmpdir(), 'claude-md-mig-'));
22
- try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
23
- }
24
-
25
- console.log('# migrate-claude-md-freshness-include');
26
-
27
- // CLAUDE.md missing → no-op
28
- withTemp((dir) => {
29
- const result = runMigration({ workspaceRoot: dir });
30
- assertEq(result.action, 'skipped', 'no CLAUDE.md is skipped');
31
- });
32
-
33
- // CLAUDE.md missing the include → appended
34
- withTemp((dir) => {
35
- const path = join(dir, 'CLAUDE.md');
36
- writeFileSync(path, '# Workspace\n@workspace.json\n');
37
- const result = runMigration({ workspaceRoot: dir });
38
- assertEq(result.action, 'appended', 'missing line is appended');
39
- const after = readFileSync(path, 'utf-8');
40
- assertEq(after.includes('@local-only-template-freshness.md'), true, 'line present after migration');
41
- });
42
-
43
- // CLAUDE.md already has the include → no-op
44
- withTemp((dir) => {
45
- const path = join(dir, 'CLAUDE.md');
46
- const original = '# Workspace\n@workspace.json\n@local-only-template-freshness.md\n';
47
- writeFileSync(path, original);
48
- const result = runMigration({ workspaceRoot: dir });
49
- assertEq(result.action, 'unchanged', 'already-present line is unchanged');
50
- assertEq(readFileSync(path, 'utf-8'), original, 'file content unchanged');
51
- });
52
-
53
- console.log(`\n${passed} passed, ${failed} failed`);
54
- process.exit(failed > 0 ? 1 : 0);