@cleocode/contracts 2026.4.68 → 2026.4.70
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/dist/__tests__/acceptance-gate.test.d.ts +14 -0
- package/dist/__tests__/acceptance-gate.test.d.ts.map +1 -0
- package/dist/__tests__/acceptance-gate.test.js +642 -0
- package/dist/__tests__/acceptance-gate.test.js.map +1 -0
- package/dist/__tests__/attachment.test.d.ts +17 -0
- package/dist/__tests__/attachment.test.d.ts.map +1 -0
- package/dist/__tests__/attachment.test.js +505 -0
- package/dist/__tests__/attachment.test.js.map +1 -0
- package/dist/acceptance-gate-schema.d.ts +624 -0
- package/dist/acceptance-gate-schema.d.ts.map +1 -0
- package/dist/acceptance-gate-schema.js +244 -0
- package/dist/acceptance-gate-schema.js.map +1 -0
- package/dist/acceptance-gate.d.ts +253 -0
- package/dist/acceptance-gate.d.ts.map +1 -0
- package/dist/acceptance-gate.js +22 -0
- package/dist/acceptance-gate.js.map +1 -0
- package/dist/attachment-schema.d.ts +243 -0
- package/dist/attachment-schema.d.ts.map +1 -0
- package/dist/attachment-schema.js +200 -0
- package/dist/attachment-schema.js.map +1 -0
- package/dist/attachment.d.ts +224 -0
- package/dist/attachment.d.ts.map +1 -0
- package/dist/attachment.js +21 -0
- package/dist/attachment.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/task.d.ts +23 -4
- package/dist/task.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/acceptance-gate.test.ts +736 -0
- package/src/__tests__/attachment.test.ts +601 -0
- package/src/acceptance-gate-schema.ts +283 -0
- package/src/acceptance-gate.ts +264 -0
- package/src/attachment-schema.ts +223 -0
- package/src/attachment.ts +245 -0
- package/src/index.ts +49 -0
- package/src/task.ts +24 -4
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip Zod parse tests for `AcceptanceGate` variants and `AcceptanceGateResult`.
|
|
3
|
+
*
|
|
4
|
+
* Each test constructs a valid input object for one gate variant, parses it
|
|
5
|
+
* through the corresponding Zod schema, and asserts that the parsed output
|
|
6
|
+
* matches the input shape exactly. This guarantees the schema accepts all
|
|
7
|
+
* valid gate shapes without transformation loss.
|
|
8
|
+
*
|
|
9
|
+
* @epic T760
|
|
10
|
+
* @task T779
|
|
11
|
+
* @task T780
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import type { AcceptanceGate, AcceptanceGateResult, FileAssertion } from '../acceptance-gate.js';
|
|
16
|
+
import {
|
|
17
|
+
acceptanceArraySchema,
|
|
18
|
+
acceptanceGateResultSchema,
|
|
19
|
+
acceptanceGateSchema,
|
|
20
|
+
commandGateSchema,
|
|
21
|
+
fileAssertionSchema,
|
|
22
|
+
fileGateSchema,
|
|
23
|
+
httpGateSchema,
|
|
24
|
+
lintGateSchema,
|
|
25
|
+
manualGateSchema,
|
|
26
|
+
testGateSchema,
|
|
27
|
+
} from '../acceptance-gate-schema.js';
|
|
28
|
+
import type { AcceptanceItem } from '../index.js';
|
|
29
|
+
|
|
30
|
+
// ─── TestGate ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe('TestGate schema', () => {
|
|
33
|
+
it('round-trips a minimal test gate', () => {
|
|
34
|
+
const input = {
|
|
35
|
+
kind: 'test' as const,
|
|
36
|
+
command: 'pnpm test',
|
|
37
|
+
expect: 'pass' as const,
|
|
38
|
+
description: 'Test suite must pass with zero failures',
|
|
39
|
+
};
|
|
40
|
+
const result = testGateSchema.parse(input);
|
|
41
|
+
expect(result.kind).toBe('test');
|
|
42
|
+
expect(result.command).toBe('pnpm test');
|
|
43
|
+
expect(result.expect).toBe('pass');
|
|
44
|
+
expect(result.description).toBe('Test suite must pass with zero failures');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('round-trips a full test gate with all optional fields', () => {
|
|
48
|
+
const input = {
|
|
49
|
+
kind: 'test' as const,
|
|
50
|
+
command: 'node',
|
|
51
|
+
args: ['--test', 'tests/*.test.mjs'],
|
|
52
|
+
expect: 'exit0' as const,
|
|
53
|
+
minCount: 3,
|
|
54
|
+
cwd: 'packages/core',
|
|
55
|
+
env: { NODE_ENV: 'test' },
|
|
56
|
+
req: 'TEST-01',
|
|
57
|
+
description: 'Core tests must run',
|
|
58
|
+
advisory: false,
|
|
59
|
+
timeoutMs: 60_000,
|
|
60
|
+
};
|
|
61
|
+
const result = testGateSchema.parse(input);
|
|
62
|
+
expect(result.req).toBe('TEST-01');
|
|
63
|
+
expect(result.minCount).toBe(3);
|
|
64
|
+
expect(result.args).toEqual(['--test', 'tests/*.test.mjs']);
|
|
65
|
+
expect(result.env).toEqual({ NODE_ENV: 'test' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('accepts "exit0" as expect value', () => {
|
|
69
|
+
const result = testGateSchema.parse({
|
|
70
|
+
kind: 'test',
|
|
71
|
+
command: 'pnpm build',
|
|
72
|
+
expect: 'exit0',
|
|
73
|
+
description: 'Build must exit cleanly',
|
|
74
|
+
});
|
|
75
|
+
expect(result.expect).toBe('exit0');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects an invalid expect value', () => {
|
|
79
|
+
const parse = () =>
|
|
80
|
+
testGateSchema.parse({
|
|
81
|
+
kind: 'test',
|
|
82
|
+
command: 'pnpm test',
|
|
83
|
+
expect: 'succeed',
|
|
84
|
+
description: 'Test',
|
|
85
|
+
});
|
|
86
|
+
expect(parse).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects empty command', () => {
|
|
90
|
+
const parse = () =>
|
|
91
|
+
testGateSchema.parse({ kind: 'test', command: '', expect: 'pass', description: 'Test' });
|
|
92
|
+
expect(parse).toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── FileGate ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('FileGate schema', () => {
|
|
99
|
+
it('round-trips a gate with exists + nonEmpty assertions', () => {
|
|
100
|
+
const input = {
|
|
101
|
+
kind: 'file' as const,
|
|
102
|
+
path: 'src/store.js',
|
|
103
|
+
assertions: [{ type: 'exists' as const }, { type: 'nonEmpty' as const }],
|
|
104
|
+
description: 'store.js must exist and be non-empty',
|
|
105
|
+
};
|
|
106
|
+
const result = fileGateSchema.parse(input);
|
|
107
|
+
expect(result.kind).toBe('file');
|
|
108
|
+
expect(result.path).toBe('src/store.js');
|
|
109
|
+
expect(result.assertions).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('round-trips a gate with all FileAssertion variants', () => {
|
|
113
|
+
const assertions: FileAssertion[] = [
|
|
114
|
+
{ type: 'exists' },
|
|
115
|
+
{ type: 'absent' },
|
|
116
|
+
{ type: 'nonEmpty' },
|
|
117
|
+
{ type: 'minBytes', value: 200 },
|
|
118
|
+
{ type: 'maxBytes', value: 10_000 },
|
|
119
|
+
{ type: 'contains', value: 'localStorage.setItem' },
|
|
120
|
+
{ type: 'matches', regex: 'work.*25', flags: 'm' },
|
|
121
|
+
{ type: 'sha256', value: 'a'.repeat(64) },
|
|
122
|
+
];
|
|
123
|
+
const input = {
|
|
124
|
+
kind: 'file' as const,
|
|
125
|
+
path: 'src/timer.js',
|
|
126
|
+
assertions,
|
|
127
|
+
description: 'Timer file matches all criteria',
|
|
128
|
+
};
|
|
129
|
+
const result = fileGateSchema.parse(input);
|
|
130
|
+
expect(result.assertions).toHaveLength(8);
|
|
131
|
+
const matchAssertion = result.assertions.find((a) => a.type === 'matches');
|
|
132
|
+
expect(matchAssertion).toBeDefined();
|
|
133
|
+
if (matchAssertion?.type === 'matches') {
|
|
134
|
+
expect(matchAssertion.flags).toBe('m');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects empty assertions array', () => {
|
|
139
|
+
const parse = () =>
|
|
140
|
+
fileGateSchema.parse({
|
|
141
|
+
kind: 'file',
|
|
142
|
+
path: 'src/x.ts',
|
|
143
|
+
assertions: [],
|
|
144
|
+
description: 'bad gate',
|
|
145
|
+
});
|
|
146
|
+
expect(parse).toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('FileAssertion schema', () => {
|
|
151
|
+
it('parses "exists" assertion', () => {
|
|
152
|
+
const result = fileAssertionSchema.parse({ type: 'exists' });
|
|
153
|
+
expect(result.type).toBe('exists');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('parses "absent" assertion', () => {
|
|
157
|
+
const result = fileAssertionSchema.parse({ type: 'absent' });
|
|
158
|
+
expect(result.type).toBe('absent');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('parses "contains" assertion', () => {
|
|
162
|
+
const result = fileAssertionSchema.parse({ type: 'contains', value: 'localStorage' });
|
|
163
|
+
expect(result.type).toBe('contains');
|
|
164
|
+
if (result.type === 'contains') expect(result.value).toBe('localStorage');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('parses "matches" assertion with flags', () => {
|
|
168
|
+
const result = fileAssertionSchema.parse({ type: 'matches', regex: 'foo.*bar', flags: 'gi' });
|
|
169
|
+
expect(result.type).toBe('matches');
|
|
170
|
+
if (result.type === 'matches') {
|
|
171
|
+
expect(result.regex).toBe('foo.*bar');
|
|
172
|
+
expect(result.flags).toBe('gi');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects unknown assertion type', () => {
|
|
177
|
+
expect(() => fileAssertionSchema.parse({ type: 'unknown' })).toThrow();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ─── CommandGate ─────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe('CommandGate schema', () => {
|
|
184
|
+
it('round-trips a minimal command gate', () => {
|
|
185
|
+
const input = {
|
|
186
|
+
kind: 'command' as const,
|
|
187
|
+
cmd: 'cleo doctor',
|
|
188
|
+
description: 'cleo doctor must exit 0',
|
|
189
|
+
};
|
|
190
|
+
const result = commandGateSchema.parse(input);
|
|
191
|
+
expect(result.kind).toBe('command');
|
|
192
|
+
expect(result.cmd).toBe('cleo doctor');
|
|
193
|
+
// exitCode defaults to undefined (not set in schema default)
|
|
194
|
+
expect(result.exitCode).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('round-trips a command gate with all optional fields', () => {
|
|
198
|
+
const input = {
|
|
199
|
+
kind: 'command' as const,
|
|
200
|
+
cmd: 'git',
|
|
201
|
+
args: ['status', '--short'],
|
|
202
|
+
exitCode: 0,
|
|
203
|
+
stdoutMatches: '^M ',
|
|
204
|
+
stderrMatches: '',
|
|
205
|
+
cwd: '.',
|
|
206
|
+
env: { GIT_TERMINAL_PROMPT: '0' },
|
|
207
|
+
description: 'working tree must have staged changes',
|
|
208
|
+
req: 'GIT-01',
|
|
209
|
+
};
|
|
210
|
+
const result = commandGateSchema.parse(input);
|
|
211
|
+
expect(result.exitCode).toBe(0);
|
|
212
|
+
expect(result.stdoutMatches).toBe('^M ');
|
|
213
|
+
expect(result.args).toEqual(['status', '--short']);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ─── LintGate ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe('LintGate schema', () => {
|
|
220
|
+
it('round-trips biome gate', () => {
|
|
221
|
+
const input = {
|
|
222
|
+
kind: 'lint' as const,
|
|
223
|
+
tool: 'biome' as const,
|
|
224
|
+
expect: 'clean' as const,
|
|
225
|
+
description: 'Source passes biome check clean',
|
|
226
|
+
};
|
|
227
|
+
const result = lintGateSchema.parse(input);
|
|
228
|
+
expect(result.kind).toBe('lint');
|
|
229
|
+
expect(result.tool).toBe('biome');
|
|
230
|
+
expect(result.expect).toBe('clean');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('accepts all lint tool values', () => {
|
|
234
|
+
const tools = ['biome', 'eslint', 'tsc', 'prettier', 'rustc', 'clippy'] as const;
|
|
235
|
+
for (const tool of tools) {
|
|
236
|
+
const result = lintGateSchema.parse({
|
|
237
|
+
kind: 'lint',
|
|
238
|
+
tool,
|
|
239
|
+
expect: 'noErrors',
|
|
240
|
+
description: `${tool} gate`,
|
|
241
|
+
});
|
|
242
|
+
expect(result.tool).toBe(tool);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('rejects unknown lint tool', () => {
|
|
247
|
+
expect(() =>
|
|
248
|
+
lintGateSchema.parse({
|
|
249
|
+
kind: 'lint',
|
|
250
|
+
tool: 'rubocop',
|
|
251
|
+
expect: 'clean',
|
|
252
|
+
description: 'bad',
|
|
253
|
+
}),
|
|
254
|
+
).toThrow();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ─── HttpGate ────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
describe('HttpGate schema', () => {
|
|
261
|
+
it('round-trips a minimal http gate', () => {
|
|
262
|
+
const input = {
|
|
263
|
+
kind: 'http' as const,
|
|
264
|
+
url: 'http://localhost:8080/',
|
|
265
|
+
status: 200,
|
|
266
|
+
description: 'App health endpoint must return 200',
|
|
267
|
+
};
|
|
268
|
+
const result = httpGateSchema.parse(input);
|
|
269
|
+
expect(result.kind).toBe('http');
|
|
270
|
+
expect(result.url).toBe('http://localhost:8080/');
|
|
271
|
+
expect(result.status).toBe(200);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('round-trips a full http gate with all optional fields', () => {
|
|
275
|
+
const input = {
|
|
276
|
+
kind: 'http' as const,
|
|
277
|
+
url: 'http://127.0.0.1:8123/',
|
|
278
|
+
method: 'GET' as const,
|
|
279
|
+
status: 200,
|
|
280
|
+
bodyMatches: '<title>[^<]*Pomodoro',
|
|
281
|
+
headers: { Accept: 'text/html' },
|
|
282
|
+
startCommand: 'npx serve -p 8123 .',
|
|
283
|
+
startupDelayMs: 2000,
|
|
284
|
+
description: 'Static app HTML shell returned',
|
|
285
|
+
req: 'SMOKE-01',
|
|
286
|
+
};
|
|
287
|
+
const result = httpGateSchema.parse(input);
|
|
288
|
+
expect(result.startupDelayMs).toBe(2000);
|
|
289
|
+
expect(result.bodyMatches).toBe('<title>[^<]*Pomodoro');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('rejects an invalid URL', () => {
|
|
293
|
+
expect(() =>
|
|
294
|
+
httpGateSchema.parse({
|
|
295
|
+
kind: 'http',
|
|
296
|
+
url: 'not-a-url',
|
|
297
|
+
status: 200,
|
|
298
|
+
description: 'bad',
|
|
299
|
+
}),
|
|
300
|
+
).toThrow();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ─── ManualGate ──────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe('ManualGate schema', () => {
|
|
307
|
+
it('round-trips a minimal manual gate', () => {
|
|
308
|
+
const input = {
|
|
309
|
+
kind: 'manual' as const,
|
|
310
|
+
prompt: 'Does the dark theme toggle work correctly?',
|
|
311
|
+
description: 'Dark/light/auto theme cycle is visually usable',
|
|
312
|
+
};
|
|
313
|
+
const result = manualGateSchema.parse(input);
|
|
314
|
+
expect(result.kind).toBe('manual');
|
|
315
|
+
expect(result.prompt).toBe('Does the dark theme toggle work correctly?');
|
|
316
|
+
expect(result.verdicts).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('round-trips a manual gate with custom verdicts', () => {
|
|
320
|
+
const input = {
|
|
321
|
+
kind: 'manual' as const,
|
|
322
|
+
prompt: 'Does the visual design match the mockup?',
|
|
323
|
+
verdicts: ['pass', 'fail', 'warn'] as const,
|
|
324
|
+
description: 'Visual design review',
|
|
325
|
+
req: 'UX-01',
|
|
326
|
+
advisory: true,
|
|
327
|
+
};
|
|
328
|
+
const result = manualGateSchema.parse(input);
|
|
329
|
+
expect(result.verdicts).toEqual(['pass', 'fail', 'warn']);
|
|
330
|
+
expect(result.advisory).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── acceptanceGateSchema (discriminated union) ───────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe('acceptanceGateSchema (discriminated union)', () => {
|
|
337
|
+
it('routes test gate by kind', () => {
|
|
338
|
+
const result = acceptanceGateSchema.parse({
|
|
339
|
+
kind: 'test',
|
|
340
|
+
command: 'pnpm test',
|
|
341
|
+
expect: 'pass',
|
|
342
|
+
description: 'Tests must pass',
|
|
343
|
+
});
|
|
344
|
+
expect(result.kind).toBe('test');
|
|
345
|
+
// TypeScript narrowing: kind is 'test' so command is available
|
|
346
|
+
if (result.kind === 'test') {
|
|
347
|
+
expect(result.command).toBe('pnpm test');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('routes file gate by kind', () => {
|
|
352
|
+
const result = acceptanceGateSchema.parse({
|
|
353
|
+
kind: 'file',
|
|
354
|
+
path: 'README.md',
|
|
355
|
+
assertions: [{ type: 'exists' }],
|
|
356
|
+
description: 'README must exist',
|
|
357
|
+
});
|
|
358
|
+
expect(result.kind).toBe('file');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('routes command gate by kind', () => {
|
|
362
|
+
const result = acceptanceGateSchema.parse({
|
|
363
|
+
kind: 'command',
|
|
364
|
+
cmd: 'pnpm doctor',
|
|
365
|
+
description: 'doctor must succeed',
|
|
366
|
+
});
|
|
367
|
+
expect(result.kind).toBe('command');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('routes lint gate by kind', () => {
|
|
371
|
+
const result = acceptanceGateSchema.parse({
|
|
372
|
+
kind: 'lint',
|
|
373
|
+
tool: 'tsc',
|
|
374
|
+
expect: 'clean',
|
|
375
|
+
description: 'TypeScript must compile clean',
|
|
376
|
+
});
|
|
377
|
+
expect(result.kind).toBe('lint');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('routes http gate by kind', () => {
|
|
381
|
+
const result = acceptanceGateSchema.parse({
|
|
382
|
+
kind: 'http',
|
|
383
|
+
url: 'https://example.com/',
|
|
384
|
+
status: 200,
|
|
385
|
+
description: 'example.com must return 200',
|
|
386
|
+
});
|
|
387
|
+
expect(result.kind).toBe('http');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('routes manual gate by kind', () => {
|
|
391
|
+
const result = acceptanceGateSchema.parse({
|
|
392
|
+
kind: 'manual',
|
|
393
|
+
prompt: 'Does it look right?',
|
|
394
|
+
description: 'Visual QA check',
|
|
395
|
+
});
|
|
396
|
+
expect(result.kind).toBe('manual');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('rejects an unknown kind', () => {
|
|
400
|
+
expect(() =>
|
|
401
|
+
acceptanceGateSchema.parse({
|
|
402
|
+
kind: 'unknown',
|
|
403
|
+
description: 'bad gate',
|
|
404
|
+
}),
|
|
405
|
+
).toThrow();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('rejects missing description', () => {
|
|
409
|
+
expect(() => acceptanceGateSchema.parse({ kind: 'manual', prompt: 'test' })).toThrow();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('type-checks inferred type is assignable to AcceptanceGate', () => {
|
|
413
|
+
const raw = {
|
|
414
|
+
kind: 'lint' as const,
|
|
415
|
+
tool: 'biome' as const,
|
|
416
|
+
expect: 'clean' as const,
|
|
417
|
+
description: 'biome clean',
|
|
418
|
+
};
|
|
419
|
+
const parsed = acceptanceGateSchema.parse(raw);
|
|
420
|
+
// This assignment verifies the inferred schema type is structurally
|
|
421
|
+
// compatible with the canonical TypeScript AcceptanceGate interface.
|
|
422
|
+
const gate: AcceptanceGate = parsed;
|
|
423
|
+
expect(gate.kind).toBe('lint');
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ─── AcceptanceGateResult schema ──────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
describe('acceptanceGateResultSchema', () => {
|
|
430
|
+
it('round-trips a pass result', () => {
|
|
431
|
+
const input = {
|
|
432
|
+
index: 0,
|
|
433
|
+
req: 'TEST-01',
|
|
434
|
+
kind: 'test' as const,
|
|
435
|
+
result: 'pass' as const,
|
|
436
|
+
durationMs: 3142,
|
|
437
|
+
evidence: 'ok 7/7 tests',
|
|
438
|
+
checkedAt: '2026-04-16T05:12:03.441Z',
|
|
439
|
+
checkedBy: 'cleo-verify',
|
|
440
|
+
};
|
|
441
|
+
const parsed = acceptanceGateResultSchema.parse(input);
|
|
442
|
+
expect(parsed.result).toBe('pass');
|
|
443
|
+
expect(parsed.durationMs).toBe(3142);
|
|
444
|
+
expect(parsed.req).toBe('TEST-01');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('round-trips a fail result with errorMessage', () => {
|
|
448
|
+
const input = {
|
|
449
|
+
index: 2,
|
|
450
|
+
kind: 'file' as const,
|
|
451
|
+
result: 'fail' as const,
|
|
452
|
+
durationMs: 4,
|
|
453
|
+
evidence: 'assertion 3 of 3 did not match',
|
|
454
|
+
errorMessage: 'File src/timer.js missing cadence setting',
|
|
455
|
+
checkedAt: '2026-04-16T05:12:05.000Z',
|
|
456
|
+
checkedBy: 'cleo-verify',
|
|
457
|
+
};
|
|
458
|
+
const parsed = acceptanceGateResultSchema.parse(input);
|
|
459
|
+
expect(parsed.result).toBe('fail');
|
|
460
|
+
expect(parsed.errorMessage).toBe('File src/timer.js missing cadence setting');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('round-trips a skipped result (--skip-manual)', () => {
|
|
464
|
+
const input = {
|
|
465
|
+
index: 6,
|
|
466
|
+
req: 'UX-01',
|
|
467
|
+
kind: 'manual' as const,
|
|
468
|
+
result: 'skipped' as const,
|
|
469
|
+
durationMs: 0,
|
|
470
|
+
evidence: '--skip-manual active',
|
|
471
|
+
checkedAt: '2026-04-16T05:12:03.000Z',
|
|
472
|
+
checkedBy: 'cleo-verify',
|
|
473
|
+
};
|
|
474
|
+
const parsed = acceptanceGateResultSchema.parse(input);
|
|
475
|
+
expect(parsed.result).toBe('skipped');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('round-trips an error result', () => {
|
|
479
|
+
const input = {
|
|
480
|
+
index: 1,
|
|
481
|
+
kind: 'command' as const,
|
|
482
|
+
result: 'error' as const,
|
|
483
|
+
durationMs: 12,
|
|
484
|
+
errorMessage: 'spawn ENOENT: command not found',
|
|
485
|
+
checkedAt: '2026-04-16T05:12:06.000Z',
|
|
486
|
+
checkedBy: 'cleo-verify',
|
|
487
|
+
};
|
|
488
|
+
const parsed = acceptanceGateResultSchema.parse(input);
|
|
489
|
+
expect(parsed.result).toBe('error');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('round-trips a warn result (advisory gate)', () => {
|
|
493
|
+
const input = {
|
|
494
|
+
index: 4,
|
|
495
|
+
kind: 'lint' as const,
|
|
496
|
+
result: 'warn' as const,
|
|
497
|
+
durationMs: 800,
|
|
498
|
+
checkedAt: '2026-04-16T05:12:07.000Z',
|
|
499
|
+
checkedBy: 'cleo-verify',
|
|
500
|
+
};
|
|
501
|
+
const parsed = acceptanceGateResultSchema.parse(input);
|
|
502
|
+
expect(parsed.result).toBe('warn');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('rejects an invalid checkedAt (non-datetime)', () => {
|
|
506
|
+
expect(() =>
|
|
507
|
+
acceptanceGateResultSchema.parse({
|
|
508
|
+
index: 0,
|
|
509
|
+
kind: 'test',
|
|
510
|
+
result: 'pass',
|
|
511
|
+
durationMs: 100,
|
|
512
|
+
checkedAt: 'not-a-date',
|
|
513
|
+
checkedBy: 'agent',
|
|
514
|
+
}),
|
|
515
|
+
).toThrow();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('type-checks inferred result is assignable to AcceptanceGateResult', () => {
|
|
519
|
+
const raw = {
|
|
520
|
+
index: 0,
|
|
521
|
+
kind: 'test' as const,
|
|
522
|
+
result: 'pass' as const,
|
|
523
|
+
durationMs: 100,
|
|
524
|
+
checkedAt: '2026-04-16T00:00:00.000Z',
|
|
525
|
+
checkedBy: 'cleo-verify',
|
|
526
|
+
};
|
|
527
|
+
const parsed = acceptanceGateResultSchema.parse(raw);
|
|
528
|
+
// Structural compatibility check with the canonical interface.
|
|
529
|
+
const typed: AcceptanceGateResult = parsed;
|
|
530
|
+
expect(typed.index).toBe(0);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ─── Mixed acceptance array (T780) ───────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
describe('acceptanceArraySchema — mixed (string | AcceptanceGate)[] (T780)', () => {
|
|
537
|
+
it('round-trips a mixed array with legacy strings and a typed gate', () => {
|
|
538
|
+
const mixedInput: AcceptanceItem[] = [
|
|
539
|
+
'legacy string 1',
|
|
540
|
+
{
|
|
541
|
+
kind: 'test',
|
|
542
|
+
command: 'npm test',
|
|
543
|
+
expect: 'pass',
|
|
544
|
+
description: 'All unit tests must pass with zero failures',
|
|
545
|
+
},
|
|
546
|
+
'legacy string 2',
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
const parsed = acceptanceArraySchema.parse(mixedInput);
|
|
550
|
+
|
|
551
|
+
// Assert the array length is preserved
|
|
552
|
+
expect(parsed).toHaveLength(3);
|
|
553
|
+
|
|
554
|
+
// First element: plain string passes through unchanged
|
|
555
|
+
expect(parsed[0]).toBe('legacy string 1');
|
|
556
|
+
|
|
557
|
+
// Second element: typed gate validated and round-tripped correctly
|
|
558
|
+
const gate = parsed[1];
|
|
559
|
+
expect(typeof gate).toBe('object');
|
|
560
|
+
if (typeof gate === 'object' && gate !== null && 'kind' in gate) {
|
|
561
|
+
expect(gate.kind).toBe('test');
|
|
562
|
+
if (gate.kind === 'test') {
|
|
563
|
+
expect(gate.command).toBe('npm test');
|
|
564
|
+
expect(gate.expect).toBe('pass');
|
|
565
|
+
expect(gate.description).toBe('All unit tests must pass with zero failures');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Third element: plain string passes through unchanged
|
|
570
|
+
expect(parsed[2]).toBe('legacy string 2');
|
|
571
|
+
|
|
572
|
+
// Verify full round-trip equality
|
|
573
|
+
expect(parsed[0]).toEqual(mixedInput[0]);
|
|
574
|
+
expect(parsed[1]).toEqual(mixedInput[1]);
|
|
575
|
+
expect(parsed[2]).toEqual(mixedInput[2]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('accepts an array of only legacy strings', () => {
|
|
579
|
+
const stringsOnly = ['Must pass linting', 'Must pass tests', 'Must build without errors'];
|
|
580
|
+
const parsed = acceptanceArraySchema.parse(stringsOnly);
|
|
581
|
+
expect(parsed).toEqual(stringsOnly);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('accepts an array of only typed gates', () => {
|
|
585
|
+
const gatesOnly: AcceptanceItem[] = [
|
|
586
|
+
{
|
|
587
|
+
kind: 'lint',
|
|
588
|
+
tool: 'biome',
|
|
589
|
+
expect: 'clean',
|
|
590
|
+
description: 'Biome must report zero issues',
|
|
591
|
+
},
|
|
592
|
+
{ kind: 'test', command: 'pnpm test', expect: 'pass', description: 'Test suite must pass' },
|
|
593
|
+
];
|
|
594
|
+
const parsed = acceptanceArraySchema.parse(gatesOnly);
|
|
595
|
+
expect(parsed).toHaveLength(2);
|
|
596
|
+
const first = parsed[0];
|
|
597
|
+
if (typeof first === 'object' && first !== null && 'kind' in first) {
|
|
598
|
+
expect(first.kind).toBe('lint');
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('rejects an empty array (T800 — require at least 1 criterion)', () => {
|
|
603
|
+
expect(() => acceptanceArraySchema.parse([])).toThrow(/at least one acceptance criterion/i);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('rejects an element with an invalid gate kind', () => {
|
|
607
|
+
// An object with an unknown 'kind' cannot be parsed as a gate and is
|
|
608
|
+
// not a plain string — the union fails on both branches.
|
|
609
|
+
expect(() =>
|
|
610
|
+
acceptanceArraySchema.parse([{ kind: 'unknown-gate', description: 'bad' }]),
|
|
611
|
+
).toThrow();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('rejects empty string in array (T800 — strings must be non-empty)', () => {
|
|
615
|
+
const result = acceptanceArraySchema.safeParse(['valid', '', 'another']);
|
|
616
|
+
expect(result.success).toBe(false);
|
|
617
|
+
if (!result.success) {
|
|
618
|
+
// Zod returns an array of errors; check that the error messages are present
|
|
619
|
+
const errorMessage = result.error.toString();
|
|
620
|
+
expect(errorMessage).toMatch(/non-empty|too_small/i);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('rejects whitespace-only string in array (T800 — strings must be non-empty after trim)', () => {
|
|
625
|
+
const result = acceptanceArraySchema.safeParse(['valid', ' ', 'another']);
|
|
626
|
+
expect(result.success).toBe(false);
|
|
627
|
+
if (!result.success) {
|
|
628
|
+
const errorMessage = result.error.toString();
|
|
629
|
+
expect(errorMessage).toMatch(/non-empty|too_small/i);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('rejects malformed gate object (T800 — missing required kind field)', () => {
|
|
634
|
+
const result = acceptanceArraySchema.safeParse([
|
|
635
|
+
'valid',
|
|
636
|
+
{ description: 'missing kind field', missing: 'kind' },
|
|
637
|
+
]);
|
|
638
|
+
expect(result.success).toBe(false);
|
|
639
|
+
if (!result.success) {
|
|
640
|
+
const errorMessage = result.error.toString();
|
|
641
|
+
// The error should indicate that the object is neither a string nor a valid gate
|
|
642
|
+
expect(errorMessage).toMatch(/invalid.*union|Invalid input/i);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('rejects duplicate req: IDs across gates (T800 — GSD-style REQ-IDs must be unique)', () => {
|
|
647
|
+
expect(() =>
|
|
648
|
+
acceptanceArraySchema.parse([
|
|
649
|
+
{
|
|
650
|
+
kind: 'test',
|
|
651
|
+
command: 'pnpm test',
|
|
652
|
+
expect: 'pass',
|
|
653
|
+
description: 'Tests pass',
|
|
654
|
+
req: 'TEST-01',
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
kind: 'file',
|
|
658
|
+
path: 'README.md',
|
|
659
|
+
assertions: [{ type: 'exists' }],
|
|
660
|
+
description: 'README exists',
|
|
661
|
+
req: 'TEST-01',
|
|
662
|
+
},
|
|
663
|
+
]),
|
|
664
|
+
).toThrow(/duplicate req/i);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('accepts mixed array with unique req: IDs (T800)', () => {
|
|
668
|
+
const parsed = acceptanceArraySchema.parse([
|
|
669
|
+
'legacy string criterion',
|
|
670
|
+
{
|
|
671
|
+
kind: 'test',
|
|
672
|
+
command: 'pnpm test',
|
|
673
|
+
expect: 'pass',
|
|
674
|
+
description: 'Tests pass',
|
|
675
|
+
req: 'TEST-01',
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
kind: 'file',
|
|
679
|
+
path: 'README.md',
|
|
680
|
+
assertions: [{ type: 'exists' }],
|
|
681
|
+
description: 'README exists',
|
|
682
|
+
req: 'README-01',
|
|
683
|
+
},
|
|
684
|
+
]);
|
|
685
|
+
expect(parsed).toHaveLength(3);
|
|
686
|
+
expect(parsed[0]).toBe('legacy string criterion');
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('accepts gates with no req: field (T800)', () => {
|
|
690
|
+
const parsed = acceptanceArraySchema.parse([
|
|
691
|
+
{
|
|
692
|
+
kind: 'test',
|
|
693
|
+
command: 'pnpm test',
|
|
694
|
+
expect: 'pass',
|
|
695
|
+
description: 'Tests pass',
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
kind: 'file',
|
|
699
|
+
path: 'README.md',
|
|
700
|
+
assertions: [{ type: 'exists' }],
|
|
701
|
+
description: 'README exists',
|
|
702
|
+
},
|
|
703
|
+
]);
|
|
704
|
+
expect(parsed).toHaveLength(2);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('accepts multiple gates with same req when not all have req (T800 — no duplicates when undefined)', () => {
|
|
708
|
+
// Multiple undefined req values should be allowed
|
|
709
|
+
const parsed = acceptanceArraySchema.parse([
|
|
710
|
+
{
|
|
711
|
+
kind: 'test',
|
|
712
|
+
command: 'pnpm test',
|
|
713
|
+
expect: 'pass',
|
|
714
|
+
description: 'Tests pass',
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
kind: 'file',
|
|
718
|
+
path: 'README.md',
|
|
719
|
+
assertions: [{ type: 'exists' }],
|
|
720
|
+
description: 'README exists',
|
|
721
|
+
},
|
|
722
|
+
]);
|
|
723
|
+
expect(parsed).toHaveLength(2);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('type-checks: parsed result is assignable to AcceptanceItem[]', () => {
|
|
727
|
+
const input: AcceptanceItem[] = [
|
|
728
|
+
'free text',
|
|
729
|
+
{ kind: 'manual', prompt: 'Does it look right?', description: 'Visual check' },
|
|
730
|
+
];
|
|
731
|
+
const parsed = acceptanceArraySchema.parse(input);
|
|
732
|
+
// Compile-time structural check: parsed type must be assignable to AcceptanceItem[]
|
|
733
|
+
const typed: AcceptanceItem[] = parsed;
|
|
734
|
+
expect(typed).toHaveLength(2);
|
|
735
|
+
});
|
|
736
|
+
});
|