@cspeach/cli 0.6.4 → 0.7.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/dist/agent/intent-system-prompt.js +46 -0
- package/dist/agent/loop.js +180 -18
- package/dist/agent/parallel-write-guard.js +71 -0
- package/dist/agent/repair-partial.js +88 -1
- package/dist/agent/retry-cap.js +121 -0
- package/dist/agent/summarise-via-provider.js +51 -0
- package/dist/approvals/jwt.js +33 -12
- package/dist/commands/auto-compact.js +94 -0
- package/dist/commands/compact.js +265 -0
- package/dist/commands/cost.js +56 -0
- package/dist/commands/help.js +21 -14
- package/dist/config/loader.js +39 -0
- package/dist/cost/cost-log.js +113 -0
- package/dist/cost/pricing.js +91 -0
- package/dist/index.js +0 -0
- package/dist/one-shot.js +1 -1
- package/dist/renderer/markdown.js +7 -1
- package/dist/renderer/status-footer.js +37 -14
- package/dist/renderer/thinking-heartbeat.js +15 -3
- package/dist/renderer/tool-widget.js +6 -1
- package/dist/renderer/tty.js +27 -0
- package/dist/repl/cspeach-shell-detect.js +33 -0
- package/dist/repl/post-turn-status.js +68 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +2 -0
- package/dist/repl.js +371 -46
- package/dist/sap-errors/parse-adt-exception.js +149 -0
- package/dist/session/schema.js +2 -1
- package/dist/session/store.js +19 -0
- package/dist/tools/_filesystem-shared.js +9 -1
- package/dist/tools/ask-question.js +19 -0
- package/dist/tools/sap-read.js +56 -4
- package/dist/tools/sap-write.js +19 -0
- package/dist/tools/subagent/schedule_draft_create.js +137 -0
- package/dist/ui/alt-screen.js +120 -0
- package/dist/ui/app.js +39 -15
- package/dist/ui/ask-question-emitter.js +13 -0
- package/dist/ui/body.js +39 -23
- package/dist/ui/coaching-picker-classic.js +8 -1
- package/dist/ui/footer.js +6 -1
- package/dist/ui/header.js +19 -4
- package/dist/ui/sap-state-store.js +13 -1
- package/dist/ui/sidebar.js +5 -3
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-row.js +30 -4
- package/dist/ui/widgets/ask-question-modal.js +132 -0
- package/dist/ui/widgets/coaching-picker.js +55 -8
- package/package.json +86 -82
- package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
- package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
- package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
- package/dist/agent/__tests__/project-context-injection.test.js +0 -161
- package/dist/agent/__tests__/repair-partial.test.js +0 -79
- package/dist/agent/__tests__/retry-key.test.js +0 -77
- package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
- package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
- package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
- package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
- package/dist/agent/__tests__/turn-stream.test.js +0 -100
- package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
- package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
- package/dist/agent/providers/__tests__/factory.test.js +0 -33
- package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
- package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
- package/dist/approvals/__tests__/advisory-render.test.js +0 -54
- package/dist/auth/__tests__/auth-file.test.js +0 -42
- package/dist/auth/__tests__/me.test.js +0 -43
- package/dist/classifier/__tests__/client.test.js +0 -70
- package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
- package/dist/commands/__tests__/login.test.js +0 -152
- package/dist/commands/__tests__/logout.test.js +0 -39
- package/dist/commands/__tests__/project-context-impact.test.js +0 -173
- package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
- package/dist/commands/__tests__/whoami.test.js +0 -91
- package/dist/config/__tests__/llm-config.test.js +0 -28
- package/dist/config/__tests__/shell-exec-config.test.js +0 -76
- package/dist/config/__tests__/write-mode.test.js +0 -36
- package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
- package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
- package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
- package/dist/project-context/__tests__/conventions.test.js +0 -221
- package/dist/project-context/__tests__/detect.test.js +0 -177
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
- package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
- package/dist/project-context/__tests__/domain-cap.test.js +0 -168
- package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
- package/dist/project-context/__tests__/git.test.js +0 -131
- package/dist/project-context/__tests__/index-files.test.js +0 -195
- package/dist/project-context/__tests__/index.test.js +0 -133
- package/dist/project-context/__tests__/render.test.js +0 -322
- package/dist/project-context/__tests__/types.test.js +0 -73
- package/dist/projects/__tests__/build.test.js +0 -52
- package/dist/projects/__tests__/canonicalize.test.js +0 -47
- package/dist/projects/__tests__/email-template.test.js +0 -71
- package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
- package/dist/projects/__tests__/extract-cca.test.js +0 -141
- package/dist/projects/__tests__/extract-design.test.js +0 -62
- package/dist/projects/__tests__/extract-estimate.test.js +0 -58
- package/dist/projects/__tests__/extract-modernize.test.js +0 -127
- package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
- package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
- package/dist/projects/__tests__/filename.test.js +0 -32
- package/dist/projects/__tests__/promote-command.test.js +0 -170
- package/dist/projects/__tests__/promote.test.js +0 -181
- package/dist/projects/__tests__/save-command.test.js +0 -216
- package/dist/projects/__tests__/save.test.js +0 -41
- package/dist/projects/__tests__/status.test.js +0 -208
- package/dist/projects/__tests__/types.test.js +0 -41
- package/dist/projects/__tests__/validate.test.js +0 -165
- package/dist/projects/__tests__/workspace.test.js +0 -337
- package/dist/repl/__tests__/current-transport.test.js +0 -43
- package/dist/repl/__tests__/diff-display.test.js +0 -59
- package/dist/repl/__tests__/file-picker.test.js +0 -97
- package/dist/repl/__tests__/rule8-detector.test.js +0 -114
- package/dist/repl/__tests__/safety-confirm.test.js +0 -130
- package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
- package/dist/sap/__tests__/system-info.test.js +0 -344
- package/dist/session/__tests__/store-discovery.test.js +0 -114
- package/dist/session/__tests__/time-ago.test.js +0 -53
- package/dist/skills/__tests__/canonical.test.js +0 -20
- package/dist/skills/__tests__/manifest-client.test.js +0 -88
- package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
- package/dist/skills/__tests__/source-managed.test.js +0 -60
- package/dist/tools/__tests__/_background-shared.test.js +0 -69
- package/dist/tools/__tests__/_command-shared.test.js +0 -59
- package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
- package/dist/tools/__tests__/_flag.test.js +0 -66
- package/dist/tools/__tests__/_helpers.js +0 -17
- package/dist/tools/__tests__/_web-shared.test.js +0 -143
- package/dist/tools/__tests__/agent_run.test.js +0 -166
- package/dist/tools/__tests__/approval-advisory.test.js +0 -66
- package/dist/tools/__tests__/background_run.test.js +0 -113
- package/dist/tools/__tests__/convention_get.test.js +0 -53
- package/dist/tools/__tests__/file-edit.test.js +0 -72
- package/dist/tools/__tests__/file-read.test.js +0 -81
- package/dist/tools/__tests__/file-write.test.js +0 -68
- package/dist/tools/__tests__/glob.test.js +0 -128
- package/dist/tools/__tests__/grep.test.js +0 -71
- package/dist/tools/__tests__/monitor_emit.test.js +0 -96
- package/dist/tools/__tests__/playbook_get.test.js +0 -109
- package/dist/tools/__tests__/project_context_get.test.js +0 -71
- package/dist/tools/__tests__/registry-category.test.js +0 -60
- package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
- package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
- package/dist/tools/__tests__/schedule_create.test.js +0 -85
- package/dist/tools/__tests__/shell_exec.test.js +0 -135
- package/dist/tools/__tests__/transport-safety.test.js +0 -106
- package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
- package/dist/tools/__tests__/web_fetch.test.js +0 -329
- package/dist/tools/__tests__/web_search.test.js +0 -215
- package/dist/tools/__tests__/write-mode.test.js +0 -32
- package/dist/ui/__tests__/login-banner.test.js +0 -69
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { runSaveCommand } from '../save-command.js';
|
|
8
|
-
import { readProjectFile } from '../status.js';
|
|
9
|
-
// ESM: derive __dirname from import.meta.url (see Task B2).
|
|
10
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const FIXTURE = readFileSync(join(__dirname, '..', '__fixtures__', 'spec-gap-skill-output.md'), 'utf8');
|
|
12
|
-
const DESIGN_FIXTURE = readFileSync(resolve(__dirname, '../../../test/fixtures/abap-design-output.md'), 'utf8');
|
|
13
|
-
const ESTIMATE_FIXTURE = readFileSync(resolve(__dirname, '../../../test/fixtures/abap-estimate-output.md'), 'utf8');
|
|
14
|
-
describe('runSaveCommand', () => {
|
|
15
|
-
it('saves file when user confirms y', async () => {
|
|
16
|
-
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-cmd-'));
|
|
17
|
-
try {
|
|
18
|
-
const log = vi.fn();
|
|
19
|
-
const path = await runSaveCommand({
|
|
20
|
-
skillOutput: FIXTURE,
|
|
21
|
-
skillName: 'abap-spec-gap',
|
|
22
|
-
skillVersion: '1.0',
|
|
23
|
-
skillInput: 'User Story: Customer Aging Report\n...',
|
|
24
|
-
tokensUsed: 7592,
|
|
25
|
-
model: 'claude-opus-4-7',
|
|
26
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
27
|
-
cwd: tmp,
|
|
28
|
-
prompt: async () => 'y',
|
|
29
|
-
log,
|
|
30
|
-
});
|
|
31
|
-
expect(path).not.toBeNull();
|
|
32
|
-
expect(existsSync(path)).toBe(true);
|
|
33
|
-
expect(log.mock.calls.flat().join('\n')).toContain('Subject: Gap analysis');
|
|
34
|
-
}
|
|
35
|
-
finally {
|
|
36
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
it('returns null when user declines', async () => {
|
|
40
|
-
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-cmd-'));
|
|
41
|
-
try {
|
|
42
|
-
const log = vi.fn();
|
|
43
|
-
const path = await runSaveCommand({
|
|
44
|
-
skillOutput: FIXTURE,
|
|
45
|
-
skillName: 'abap-spec-gap',
|
|
46
|
-
skillVersion: '1.0',
|
|
47
|
-
skillInput: 'User Story: ...',
|
|
48
|
-
tokensUsed: 1000,
|
|
49
|
-
model: 'm',
|
|
50
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
51
|
-
cwd: tmp,
|
|
52
|
-
prompt: async () => 'n',
|
|
53
|
-
log,
|
|
54
|
-
});
|
|
55
|
-
expect(path).toBeNull();
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
it('returns null when skill output is not parseable as spec-gap', async () => {
|
|
62
|
-
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-cmd-'));
|
|
63
|
-
try {
|
|
64
|
-
const log = vi.fn();
|
|
65
|
-
const path = await runSaveCommand({
|
|
66
|
-
skillOutput: 'Some other skill output',
|
|
67
|
-
skillName: 'abap-radar',
|
|
68
|
-
skillVersion: '1.0',
|
|
69
|
-
skillInput: '...',
|
|
70
|
-
tokensUsed: 100,
|
|
71
|
-
model: 'm',
|
|
72
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
73
|
-
cwd: tmp,
|
|
74
|
-
prompt: async () => 'y',
|
|
75
|
-
log,
|
|
76
|
-
});
|
|
77
|
-
expect(path).toBeNull();
|
|
78
|
-
}
|
|
79
|
-
finally {
|
|
80
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
describe('runSaveCommand — design dispatch', () => {
|
|
85
|
-
it('saves a /abap-design output as a cspeach-design envelope', async () => {
|
|
86
|
-
const cwd = mkdtempSync(`${tmpdir()}/cspeach-test-`);
|
|
87
|
-
try {
|
|
88
|
-
const log = [];
|
|
89
|
-
const path = await runSaveCommand({
|
|
90
|
-
skillOutput: DESIGN_FIXTURE,
|
|
91
|
-
skillName: 'abap-design',
|
|
92
|
-
skillVersion: '1.0',
|
|
93
|
-
skillInput: 'Order Approval System',
|
|
94
|
-
tokensUsed: 5000,
|
|
95
|
-
model: 'claude-opus-4-7',
|
|
96
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
97
|
-
cwd,
|
|
98
|
-
prompt: async () => 'y',
|
|
99
|
-
log: (...lines) => log.push(...lines),
|
|
100
|
-
});
|
|
101
|
-
expect(path).toBeTruthy();
|
|
102
|
-
expect(path).toMatch(/order-approval-system-design-[a-f0-9]{4}-v1\.cspeach\.json$/);
|
|
103
|
-
const env = readProjectFile(path);
|
|
104
|
-
expect(env.artefactType).toBe('design');
|
|
105
|
-
expect(env.content.objects).toHaveLength(11);
|
|
106
|
-
const emailLines = log.join('\n');
|
|
107
|
-
expect(emailLines).toContain('11 objects');
|
|
108
|
-
expect(emailLines).toContain('design');
|
|
109
|
-
}
|
|
110
|
-
finally {
|
|
111
|
-
rmSync(cwd, { recursive: true, force: true });
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
it('returns null and prints nothing for unsupported skill names', async () => {
|
|
115
|
-
const log = [];
|
|
116
|
-
const path = await runSaveCommand({
|
|
117
|
-
skillOutput: '## Anything\n',
|
|
118
|
-
skillName: 'abap-not-a-skill',
|
|
119
|
-
skillVersion: '1.0',
|
|
120
|
-
skillInput: '',
|
|
121
|
-
tokensUsed: 0,
|
|
122
|
-
model: 'claude-opus-4-7',
|
|
123
|
-
author: { name: 'L', role: 'consultant' },
|
|
124
|
-
cwd: '.',
|
|
125
|
-
prompt: async () => 'y',
|
|
126
|
-
log: (...lines) => log.push(...lines),
|
|
127
|
-
});
|
|
128
|
-
expect(path).toBeNull();
|
|
129
|
-
expect(log).toEqual([]);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
describe('runSaveCommand — estimate dispatch', () => {
|
|
133
|
-
it('saves a /abap-estimate output as a cspeach-estimate envelope', async () => {
|
|
134
|
-
const cwd = mkdtempSync(`${tmpdir()}/cspeach-test-`);
|
|
135
|
-
try {
|
|
136
|
-
const log = [];
|
|
137
|
-
const path = await runSaveCommand({
|
|
138
|
-
skillOutput: ESTIMATE_FIXTURE,
|
|
139
|
-
skillName: 'abap-estimate',
|
|
140
|
-
skillVersion: '1.0',
|
|
141
|
-
skillInput: 'ZSD-441',
|
|
142
|
-
tokensUsed: 4200,
|
|
143
|
-
model: 'claude-opus-4-7',
|
|
144
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
145
|
-
cwd,
|
|
146
|
-
prompt: async () => 'y',
|
|
147
|
-
log: (...lines) => log.push(...lines),
|
|
148
|
-
});
|
|
149
|
-
expect(path).toBeTruthy();
|
|
150
|
-
expect(path).toMatch(/zsd-441-material-pricing-report-with-enhancement-estimate-[a-f0-9]{4}-v1\.cspeach\.json$/);
|
|
151
|
-
const env = readProjectFile(path);
|
|
152
|
-
expect(env.artefactType).toBe('estimate');
|
|
153
|
-
expect(env.content.components).toHaveLength(11);
|
|
154
|
-
const emailLines = log.join('\n');
|
|
155
|
-
expect(emailLines).toContain('11 components');
|
|
156
|
-
}
|
|
157
|
-
finally {
|
|
158
|
-
rmSync(cwd, { recursive: true, force: true });
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
describe('runSaveCommand — promotedFrom threading', () => {
|
|
163
|
-
it('writes promotedFrom into the saved envelope when supplied', async () => {
|
|
164
|
-
const cwd = mkdtempSync(`${tmpdir()}/cspeach-test-`);
|
|
165
|
-
try {
|
|
166
|
-
const promotedFrom = {
|
|
167
|
-
id: 'src-uuid', title: 'Source Spec',
|
|
168
|
-
artefactType: 'spec-gap', version: 3,
|
|
169
|
-
sha256: 'a'.repeat(64),
|
|
170
|
-
snapshottedAt: '2026-05-05T10:00:00Z',
|
|
171
|
-
snapshot: { items: [{ id: 'gap-001', text: 'Q', severity: 'BLOCKER', status: 'answered', answer: 'A' }] },
|
|
172
|
-
};
|
|
173
|
-
const log = [];
|
|
174
|
-
const path = await runSaveCommand({
|
|
175
|
-
skillOutput: DESIGN_FIXTURE,
|
|
176
|
-
skillName: 'abap-design',
|
|
177
|
-
skillVersion: '1.0',
|
|
178
|
-
skillInput: '',
|
|
179
|
-
tokensUsed: 0,
|
|
180
|
-
model: 'claude-opus-4-7',
|
|
181
|
-
author: { name: 'L', role: 'consultant' },
|
|
182
|
-
cwd,
|
|
183
|
-
prompt: async () => 'y',
|
|
184
|
-
log: (...l) => log.push(...l),
|
|
185
|
-
promotedFrom,
|
|
186
|
-
});
|
|
187
|
-
const env = readProjectFile(path);
|
|
188
|
-
expect(env.promotedFrom).toEqual(promotedFrom);
|
|
189
|
-
}
|
|
190
|
-
finally {
|
|
191
|
-
rmSync(cwd, { recursive: true, force: true });
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
it('writes promotedFrom: null when omitted (existing-behaviour pin)', async () => {
|
|
195
|
-
const cwd = mkdtempSync(`${tmpdir()}/cspeach-test-`);
|
|
196
|
-
try {
|
|
197
|
-
const path = await runSaveCommand({
|
|
198
|
-
skillOutput: DESIGN_FIXTURE,
|
|
199
|
-
skillName: 'abap-design',
|
|
200
|
-
skillVersion: '1.0',
|
|
201
|
-
skillInput: '',
|
|
202
|
-
tokensUsed: 0,
|
|
203
|
-
model: 'claude-opus-4-7',
|
|
204
|
-
author: { name: 'L', role: 'consultant' },
|
|
205
|
-
cwd,
|
|
206
|
-
prompt: async () => 'y',
|
|
207
|
-
log: () => { },
|
|
208
|
-
});
|
|
209
|
-
const env = readProjectFile(path);
|
|
210
|
-
expect(env.promotedFrom).toBeNull();
|
|
211
|
-
}
|
|
212
|
-
finally {
|
|
213
|
-
rmSync(cwd, { recursive: true, force: true });
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { saveProject } from '../save.js';
|
|
6
|
-
import { buildEnvelope } from '../build.js';
|
|
7
|
-
const SAMPLE_EXTRACT = {
|
|
8
|
-
title: 'Customer Aging Report',
|
|
9
|
-
content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [] },
|
|
10
|
-
items: [],
|
|
11
|
-
};
|
|
12
|
-
let tmp;
|
|
13
|
-
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'cspeach-save-')); });
|
|
14
|
-
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
|
15
|
-
describe('saveProject', () => {
|
|
16
|
-
it('writes a file at <cwd>/<filename> and returns the path', async () => {
|
|
17
|
-
const env = buildEnvelope({
|
|
18
|
-
artefactType: 'spec-gap',
|
|
19
|
-
extract: SAMPLE_EXTRACT,
|
|
20
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
21
|
-
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 5000, model: 'claude-opus-4-7' },
|
|
22
|
-
});
|
|
23
|
-
const path = await saveProject(env, { cwd: tmp });
|
|
24
|
-
expect(existsSync(path)).toBe(true);
|
|
25
|
-
expect(path).toMatch(/customer-aging-report-spec-gap-[0-9a-f]{4}-v1\.cspeach\.json$/);
|
|
26
|
-
const written = JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
-
expect(written.title).toBe('Customer Aging Report');
|
|
28
|
-
});
|
|
29
|
-
it('refuses to overwrite an existing file (returns suffixed name)', async () => {
|
|
30
|
-
const env = buildEnvelope({
|
|
31
|
-
artefactType: 'spec-gap',
|
|
32
|
-
extract: SAMPLE_EXTRACT,
|
|
33
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
34
|
-
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 5000, model: 'claude-opus-4-7' },
|
|
35
|
-
});
|
|
36
|
-
const path1 = await saveProject(env, { cwd: tmp });
|
|
37
|
-
const path2 = await saveProject(env, { cwd: tmp });
|
|
38
|
-
expect(path1).not.toBe(path2);
|
|
39
|
-
expect(path2).toMatch(/-v1-\d+\.cspeach\.json$/);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { renderStatus, readProjectFile, renderMultiFileStatus } from '../status.js';
|
|
6
|
-
import { buildEnvelope } from '../build.js';
|
|
7
|
-
const SAMPLE = buildEnvelope({
|
|
8
|
-
artefactType: 'spec-gap',
|
|
9
|
-
extract: {
|
|
10
|
-
title: 'Customer Aging Report',
|
|
11
|
-
content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [] },
|
|
12
|
-
items: [
|
|
13
|
-
{ id: 'gap-001', status: 'answered', answer: 'BSID', answeredBy: { name: 'Sarah', role: 'BA' }, answeredAt: '2026-05-04T09:00:00Z', comments: [] },
|
|
14
|
-
{ id: 'gap-002', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
15
|
-
{ id: 'gap-003', status: 'deferred', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
16
|
-
{ id: 'gap-004', status: 'n-a', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
17
|
-
],
|
|
18
|
-
},
|
|
19
|
-
author: { name: 'Laeeq', role: 'consultant' },
|
|
20
|
-
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 100, model: 'm' },
|
|
21
|
-
});
|
|
22
|
-
describe('renderStatus', () => {
|
|
23
|
-
it('renders correct counts', () => {
|
|
24
|
-
const out = renderStatus(SAMPLE);
|
|
25
|
-
expect(out).toContain('Customer Aging Report');
|
|
26
|
-
expect(out).toContain('1 / 4 answered');
|
|
27
|
-
expect(out).toContain('1 deferred');
|
|
28
|
-
expect(out).toContain('1 n/a');
|
|
29
|
-
expect(out).toContain('1 open');
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
describe('readProjectFile', () => {
|
|
33
|
-
it('reads and validates a project file', () => {
|
|
34
|
-
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-status-'));
|
|
35
|
-
try {
|
|
36
|
-
const path = join(tmp, 'sample.cspeach.json');
|
|
37
|
-
writeFileSync(path, JSON.stringify(SAMPLE, null, 2));
|
|
38
|
-
const env = readProjectFile(path);
|
|
39
|
-
expect(env.title).toBe('Customer Aging Report');
|
|
40
|
-
}
|
|
41
|
-
finally {
|
|
42
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
it('throws on invalid file', () => {
|
|
46
|
-
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-status-'));
|
|
47
|
-
try {
|
|
48
|
-
const path = join(tmp, 'bad.cspeach.json');
|
|
49
|
-
writeFileSync(path, '{}');
|
|
50
|
-
expect(() => readProjectFile(path)).toThrow();
|
|
51
|
-
}
|
|
52
|
-
finally {
|
|
53
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
/**
|
|
58
|
-
* Build two v3 envelopes that share the same id and a common ancestor (v2)
|
|
59
|
-
* in their history arrays, but diverge in interaction.items at v3.
|
|
60
|
-
* Spec §9: "share the same id AND their history arrays agree up to the most
|
|
61
|
-
* recent entry that pre-dates the divergence".
|
|
62
|
-
*/
|
|
63
|
-
function makeConflictPair() {
|
|
64
|
-
const sharedId = '22222222-2222-4222-8222-222222222222';
|
|
65
|
-
const sharedAncestorHistory = [
|
|
66
|
-
{ at: '2026-05-03T10:00:00Z', by: { name: 'Laeeq', role: 'consultant' }, action: 'created' },
|
|
67
|
-
{ at: '2026-05-04T08:00:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: '0 gaps answered' },
|
|
68
|
-
];
|
|
69
|
-
const baseItems = [
|
|
70
|
-
{ id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
71
|
-
{ id: 'gap-002', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
72
|
-
];
|
|
73
|
-
const skeleton = {
|
|
74
|
-
schemaVersion: '1.0',
|
|
75
|
-
artefactType: 'spec-gap',
|
|
76
|
-
id: sharedId,
|
|
77
|
-
title: 'Customer Aging Report',
|
|
78
|
-
createdAt: '2026-05-03T10:00:00Z',
|
|
79
|
-
createdBy: { name: 'Laeeq', role: 'consultant' },
|
|
80
|
-
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 100, model: 'm' },
|
|
81
|
-
promotedFrom: null,
|
|
82
|
-
content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [] },
|
|
83
|
-
};
|
|
84
|
-
const fromSarah = {
|
|
85
|
-
...skeleton,
|
|
86
|
-
version: 3,
|
|
87
|
-
lastEditedAt: '2026-05-04T09:30:00Z',
|
|
88
|
-
lastEditedBy: { name: 'Sarah', role: 'BA' },
|
|
89
|
-
history: [
|
|
90
|
-
...sharedAncestorHistory,
|
|
91
|
-
{ at: '2026-05-04T09:30:00Z', by: { name: 'Sarah', role: 'BA' }, action: 'edited', summary: '1 gap answered' },
|
|
92
|
-
],
|
|
93
|
-
interaction: { items: [
|
|
94
|
-
{ ...baseItems[0], status: 'answered', answer: 'Sarah answer', answeredBy: { name: 'Sarah', role: 'BA' }, answeredAt: '2026-05-04T09:30:00Z' },
|
|
95
|
-
baseItems[1],
|
|
96
|
-
] },
|
|
97
|
-
};
|
|
98
|
-
const fromAhmed = {
|
|
99
|
-
...skeleton,
|
|
100
|
-
version: 3,
|
|
101
|
-
lastEditedAt: '2026-05-04T11:00:00Z',
|
|
102
|
-
lastEditedBy: { name: 'Ahmed', role: 'BA' },
|
|
103
|
-
history: [
|
|
104
|
-
...sharedAncestorHistory,
|
|
105
|
-
{ at: '2026-05-04T11:00:00Z', by: { name: 'Ahmed', role: 'BA' }, action: 'edited', summary: '1 gap deferred' },
|
|
106
|
-
],
|
|
107
|
-
interaction: { items: [
|
|
108
|
-
baseItems[0],
|
|
109
|
-
{ ...baseItems[1], status: 'deferred' },
|
|
110
|
-
] },
|
|
111
|
-
};
|
|
112
|
-
return [fromSarah, fromAhmed];
|
|
113
|
-
}
|
|
114
|
-
describe('renderMultiFileStatus', () => {
|
|
115
|
-
it('warns when two files share source ancestor but have different SHAs', async () => {
|
|
116
|
-
const [env1, env2] = makeConflictPair();
|
|
117
|
-
const out = await renderMultiFileStatus([env1, env2]);
|
|
118
|
-
expect(out).toContain('Two v3');
|
|
119
|
-
expect(out).toContain('files received');
|
|
120
|
-
expect(out).toContain('parallel edit');
|
|
121
|
-
});
|
|
122
|
-
it('does not warn when SHAs match (identical envelopes)', async () => {
|
|
123
|
-
const [env1] = makeConflictPair();
|
|
124
|
-
const out = await renderMultiFileStatus([env1, env1]);
|
|
125
|
-
expect(out).not.toContain('parallel edit');
|
|
126
|
-
});
|
|
127
|
-
it('does not warn when ids differ (different sources, no parallel edit)', async () => {
|
|
128
|
-
const [env1] = makeConflictPair();
|
|
129
|
-
const env2 = { ...env1, id: '33333333-3333-4333-8333-333333333333' };
|
|
130
|
-
const out = await renderMultiFileStatus([env1, env2]);
|
|
131
|
-
expect(out).not.toContain('parallel edit');
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
const baseEnv = {
|
|
135
|
-
schemaVersion: '1.0',
|
|
136
|
-
id: '11111111-1111-1111-1111-111111111111',
|
|
137
|
-
title: 'T',
|
|
138
|
-
createdAt: '2026-05-05T00:00:00Z',
|
|
139
|
-
createdBy: { name: 'A', role: 'consultant' },
|
|
140
|
-
lastEditedAt: '2026-05-05T09:00:00Z',
|
|
141
|
-
lastEditedBy: { name: 'B', role: 'arch' },
|
|
142
|
-
version: 1,
|
|
143
|
-
source: { skill: 's', skillVersion: '1.0', input: '', tokensUsed: 0, model: 'claude-opus-4-7' },
|
|
144
|
-
promotedFrom: null,
|
|
145
|
-
history: [{ at: '2026-05-05T00:00:00Z', by: { name: 'A', role: 'consultant' }, action: 'created' }],
|
|
146
|
-
};
|
|
147
|
-
describe('renderStatus — design', () => {
|
|
148
|
-
const env = {
|
|
149
|
-
...baseEnv, artefactType: 'design',
|
|
150
|
-
content: { objects: [
|
|
151
|
-
{ id: 'obj-001', name: 'ZCL_AGING', type: 'CLAS', package: 'ZAGING', dependsOn: [] },
|
|
152
|
-
{ id: 'obj-002', name: 'ZTBL_AGING', type: 'TABL', package: 'ZAGING', dependsOn: ['obj-001'] },
|
|
153
|
-
] },
|
|
154
|
-
interaction: { items: [
|
|
155
|
-
{ id: 'obj-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
156
|
-
{ id: 'obj-002', status: 'redesign', answer: 'split into two', answeredBy: { name: 'B', role: 'arch' }, answeredAt: '2026-05-05T09:00:00Z', comments: [] },
|
|
157
|
-
] },
|
|
158
|
-
};
|
|
159
|
-
it('renders status counts using design enum', () => {
|
|
160
|
-
const out = renderStatus(env);
|
|
161
|
-
expect(out).toContain('design (v1)');
|
|
162
|
-
expect(out).toContain('1 / 2 keep');
|
|
163
|
-
expect(out).toContain('1 redesign');
|
|
164
|
-
});
|
|
165
|
-
it('renders open-list as "no decisions yet" entries (status not in primary/secondary terminals shown explicitly is N/A in design — design has no "open" terminal)', () => {
|
|
166
|
-
const out = renderStatus(env);
|
|
167
|
-
expect(out).not.toContain('Open (');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
describe('renderStatus — estimate', () => {
|
|
171
|
-
const env = {
|
|
172
|
-
...baseEnv, artefactType: 'estimate',
|
|
173
|
-
content: { components: [
|
|
174
|
-
{ id: 'cmp-001', name: 'Aging core', hoursOptimistic: 6, hoursRealistic: 10, hoursPessimistic: 16, risks: [] },
|
|
175
|
-
{ id: 'cmp-002', name: 'Currency conversion', hoursOptimistic: 4, hoursRealistic: 8, hoursPessimistic: 14, risks: ['unclear FX rates'] },
|
|
176
|
-
] },
|
|
177
|
-
interaction: { items: [
|
|
178
|
-
{ id: 'cmp-001', status: 'confirmed', answer: 'OK', answeredBy: { name: 'B', role: 'PM' }, answeredAt: '2026-05-05T09:00:00Z', comments: [] },
|
|
179
|
-
{ id: 'cmp-002', status: 'disputed', answer: 'too low', answeredBy: { name: 'B', role: 'PM' }, answeredAt: '2026-05-05T09:00:00Z', comments: [] },
|
|
180
|
-
] },
|
|
181
|
-
};
|
|
182
|
-
it('renders status counts using estimate enum', () => {
|
|
183
|
-
const out = renderStatus(env);
|
|
184
|
-
expect(out).toContain('estimate (v1)');
|
|
185
|
-
expect(out).toContain('1 / 2 confirmed');
|
|
186
|
-
expect(out).toContain('1 disputed');
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
describe('renderStatus — promote hint by artefact', () => {
|
|
190
|
-
it('spec-gap with answered items recommends /abap-design --from', () => {
|
|
191
|
-
const env = {
|
|
192
|
-
...baseEnv, artefactType: 'spec-gap',
|
|
193
|
-
content: { inputType: 'X', inferredDomain: 'FI', sections: [{ name: 'G', audience: 'business', gaps: [{ id: 'gap-001', severity: 'BLOCKER', text: 'Q' }] }] },
|
|
194
|
-
interaction: { items: [{ id: 'gap-001', status: 'answered', answer: 'A', answeredBy: { name: 'B', role: 'BA' }, answeredAt: '2026-05-05T09:00:00Z', comments: [] }] },
|
|
195
|
-
};
|
|
196
|
-
const out = renderStatus(env);
|
|
197
|
-
expect(out).toContain('/abap-design --from');
|
|
198
|
-
});
|
|
199
|
-
it('design with kept items recommends /abap-estimate --from', () => {
|
|
200
|
-
const env = {
|
|
201
|
-
...baseEnv, artefactType: 'design',
|
|
202
|
-
content: { objects: [{ id: 'obj-001', name: 'ZCL', type: 'CLAS', package: 'P', dependsOn: [] }] },
|
|
203
|
-
interaction: { items: [{ id: 'obj-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] }] },
|
|
204
|
-
};
|
|
205
|
-
const out = renderStatus(env);
|
|
206
|
-
expect(out).toContain('/abap-estimate --from');
|
|
207
|
-
});
|
|
208
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
-
describe('cspeach types', () => {
|
|
3
|
-
it('exposes ArtefactType union of 11 known kinds (v0.7 adds modernize-result + test-coverage)', () => {
|
|
4
|
-
expectTypeOf().toEqualTypeOf();
|
|
5
|
-
});
|
|
6
|
-
it('exposes per-artefact status enums', () => {
|
|
7
|
-
expectTypeOf().toEqualTypeOf();
|
|
8
|
-
expectTypeOf().toEqualTypeOf();
|
|
9
|
-
expectTypeOf().toEqualTypeOf();
|
|
10
|
-
expectTypeOf().toEqualTypeOf();
|
|
11
|
-
expectTypeOf().toEqualTypeOf();
|
|
12
|
-
expectTypeOf().toEqualTypeOf();
|
|
13
|
-
expectTypeOf().toEqualTypeOf();
|
|
14
|
-
expectTypeOf().toEqualTypeOf();
|
|
15
|
-
expectTypeOf().toEqualTypeOf();
|
|
16
|
-
expectTypeOf().toEqualTypeOf();
|
|
17
|
-
expectTypeOf().toEqualTypeOf();
|
|
18
|
-
});
|
|
19
|
-
it('Identity is { name, role }', () => {
|
|
20
|
-
expectTypeOf().toEqualTypeOf();
|
|
21
|
-
});
|
|
22
|
-
it('CspeachEnvelope has required envelope fields', () => {
|
|
23
|
-
expectTypeOf().toMatchTypeOf();
|
|
24
|
-
});
|
|
25
|
-
it('Comment has required shape', () => {
|
|
26
|
-
expectTypeOf().toMatchTypeOf();
|
|
27
|
-
});
|
|
28
|
-
it('HistoryEntry action is the literal union', () => {
|
|
29
|
-
expectTypeOf().toEqualTypeOf();
|
|
30
|
-
});
|
|
31
|
-
it('InteractionItem.comments is Comment[]', () => {
|
|
32
|
-
expectTypeOf().toEqualTypeOf();
|
|
33
|
-
});
|
|
34
|
-
it('SpecGapContent.sections is an array', () => {
|
|
35
|
-
expectTypeOf().toBeArray();
|
|
36
|
-
});
|
|
37
|
-
it('narrows content via artefactType discriminator', () => {
|
|
38
|
-
expectTypeOf().toEqualTypeOf();
|
|
39
|
-
expectTypeOf().toEqualTypeOf();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validateEnvelope } from '../validate.js';
|
|
3
|
-
const VALID_MIN_SPEC_GAP = {
|
|
4
|
-
schemaVersion: '1.0',
|
|
5
|
-
artefactType: 'spec-gap',
|
|
6
|
-
id: '11111111-1111-4111-8111-111111111111',
|
|
7
|
-
title: 'Customer Aging Report',
|
|
8
|
-
createdAt: '2026-05-04T10:00:00Z',
|
|
9
|
-
createdBy: { name: 'Laeeq', role: 'consultant' },
|
|
10
|
-
lastEditedAt: '2026-05-04T10:00:00Z',
|
|
11
|
-
lastEditedBy: { name: 'Laeeq', role: 'consultant' },
|
|
12
|
-
version: 1,
|
|
13
|
-
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '...', tokensUsed: 1000, model: 'claude-opus-4-7' },
|
|
14
|
-
promotedFrom: null,
|
|
15
|
-
content: { inputType: 'User Story', inferredDomain: 'FI-AR', sections: [] },
|
|
16
|
-
interaction: { items: [] },
|
|
17
|
-
history: [{ at: '2026-05-04T10:00:00Z', by: { name: 'Laeeq', role: 'consultant' }, action: 'created' }],
|
|
18
|
-
};
|
|
19
|
-
describe('validateEnvelope', () => {
|
|
20
|
-
it('accepts a minimal valid spec-gap envelope', () => {
|
|
21
|
-
const result = validateEnvelope(VALID_MIN_SPEC_GAP);
|
|
22
|
-
expect(result.ok).toBe(true);
|
|
23
|
-
});
|
|
24
|
-
it('rejects missing schemaVersion', () => {
|
|
25
|
-
const bad = { ...VALID_MIN_SPEC_GAP };
|
|
26
|
-
delete bad.schemaVersion;
|
|
27
|
-
const result = validateEnvelope(bad);
|
|
28
|
-
expect(result.ok).toBe(false);
|
|
29
|
-
if (!result.ok) {
|
|
30
|
-
expect(result.error.code).toBe('MISSING_FIELD');
|
|
31
|
-
expect(result.error.field).toBe('schemaVersion');
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
it('rejects unknown artefactType', () => {
|
|
35
|
-
const bad = { ...VALID_MIN_SPEC_GAP, artefactType: 'invalid' };
|
|
36
|
-
const result = validateEnvelope(bad);
|
|
37
|
-
expect(result.ok).toBe(false);
|
|
38
|
-
if (!result.ok)
|
|
39
|
-
expect(result.error.code).toBe('INVALID_VALUE');
|
|
40
|
-
});
|
|
41
|
-
it('rejects negative version', () => {
|
|
42
|
-
const bad = { ...VALID_MIN_SPEC_GAP, version: -1 };
|
|
43
|
-
const result = validateEnvelope(bad);
|
|
44
|
-
expect(result.ok).toBe(false);
|
|
45
|
-
});
|
|
46
|
-
it('rejects version mismatch with history.length', () => {
|
|
47
|
-
const bad = { ...VALID_MIN_SPEC_GAP, version: 5 }; // history has 1 entry
|
|
48
|
-
const result = validateEnvelope(bad);
|
|
49
|
-
expect(result.ok).toBe(false);
|
|
50
|
-
if (!result.ok)
|
|
51
|
-
expect(result.error.code).toBe('VERSION_MISMATCH');
|
|
52
|
-
});
|
|
53
|
-
it('rejects items without stable id', () => {
|
|
54
|
-
const bad = {
|
|
55
|
-
...VALID_MIN_SPEC_GAP,
|
|
56
|
-
interaction: { items: [{ status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] }] },
|
|
57
|
-
};
|
|
58
|
-
const result = validateEnvelope(bad);
|
|
59
|
-
expect(result.ok).toBe(false);
|
|
60
|
-
if (!result.ok)
|
|
61
|
-
expect(result.error.code).toBe('MISSING_FIELD');
|
|
62
|
-
});
|
|
63
|
-
it('rejects status not in spec-gap enum', () => {
|
|
64
|
-
const bad = {
|
|
65
|
-
...VALID_MIN_SPEC_GAP,
|
|
66
|
-
interaction: { items: [{ id: 'gap-001', status: 'keep', answer: null, answeredBy: null, answeredAt: null, comments: [] }] },
|
|
67
|
-
};
|
|
68
|
-
const result = validateEnvelope(bad);
|
|
69
|
-
expect(result.ok).toBe(false);
|
|
70
|
-
if (!result.ok)
|
|
71
|
-
expect(result.error.code).toBe('INVALID_VALUE');
|
|
72
|
-
});
|
|
73
|
-
it('warns on schemaVersion newer than supported', () => {
|
|
74
|
-
const bad = { ...VALID_MIN_SPEC_GAP, schemaVersion: '2.0' };
|
|
75
|
-
const result = validateEnvelope(bad);
|
|
76
|
-
expect(result.ok).toBe(true); // best-effort parse
|
|
77
|
-
expect(result.warnings).toContain('SCHEMA_TOO_NEW');
|
|
78
|
-
});
|
|
79
|
-
it('rejects null root', () => {
|
|
80
|
-
expect(validateEnvelope(null).ok).toBe(false);
|
|
81
|
-
});
|
|
82
|
-
it('rejects non-object root (array)', () => {
|
|
83
|
-
expect(validateEnvelope([]).ok).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
it('rejects non-object root (string)', () => {
|
|
86
|
-
expect(validateEnvelope('hello').ok).toBe(false);
|
|
87
|
-
});
|
|
88
|
-
it('treats null required field as MISSING_FIELD', () => {
|
|
89
|
-
const bad = { ...VALID_MIN_SPEC_GAP, title: null };
|
|
90
|
-
const r = validateEnvelope(bad);
|
|
91
|
-
expect(r.ok).toBe(false);
|
|
92
|
-
if (!r.ok)
|
|
93
|
-
expect(r.error.code).toBe('MISSING_FIELD');
|
|
94
|
-
});
|
|
95
|
-
it('treats empty-string required field as MISSING_FIELD', () => {
|
|
96
|
-
const bad = { ...VALID_MIN_SPEC_GAP, title: '' };
|
|
97
|
-
const r = validateEnvelope(bad);
|
|
98
|
-
expect(r.ok).toBe(false);
|
|
99
|
-
if (!r.ok)
|
|
100
|
-
expect(r.error.code).toBe('MISSING_FIELD');
|
|
101
|
-
});
|
|
102
|
-
it('rejects malformed schemaVersion (not <int>.<int>)', () => {
|
|
103
|
-
const bad = { ...VALID_MIN_SPEC_GAP, schemaVersion: 'hello' };
|
|
104
|
-
const r = validateEnvelope(bad);
|
|
105
|
-
expect(r.ok).toBe(false);
|
|
106
|
-
if (!r.ok)
|
|
107
|
-
expect(r.error.code).toBe('INVALID_VALUE');
|
|
108
|
-
});
|
|
109
|
-
it('accepts patch version of supported major.minor (1.0.5)', () => {
|
|
110
|
-
const ok = { ...VALID_MIN_SPEC_GAP, schemaVersion: '1.0.5' };
|
|
111
|
-
const r = validateEnvelope(ok);
|
|
112
|
-
expect(r.ok).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
it('warns SCHEMA_TOO_NEW on higher minor (1.1)', () => {
|
|
115
|
-
const future = { ...VALID_MIN_SPEC_GAP, schemaVersion: '1.1' };
|
|
116
|
-
const r = validateEnvelope(future);
|
|
117
|
-
expect(r.ok).toBe(true);
|
|
118
|
-
expect(r.warnings).toContain('SCHEMA_TOO_NEW');
|
|
119
|
-
});
|
|
120
|
-
it('warns SCHEMA_TOO_NEW on higher major (2.0)', () => {
|
|
121
|
-
const future = { ...VALID_MIN_SPEC_GAP, schemaVersion: '2.0' };
|
|
122
|
-
const r = validateEnvelope(future);
|
|
123
|
-
expect(r.ok).toBe(true);
|
|
124
|
-
expect(r.warnings).toContain('SCHEMA_TOO_NEW');
|
|
125
|
-
});
|
|
126
|
-
it('rejects null in interaction.items[]', () => {
|
|
127
|
-
const bad = { ...VALID_MIN_SPEC_GAP, interaction: { items: [null] } };
|
|
128
|
-
expect(validateEnvelope(bad).ok).toBe(false);
|
|
129
|
-
});
|
|
130
|
-
it('rejects string in interaction.items[]', () => {
|
|
131
|
-
const bad = { ...VALID_MIN_SPEC_GAP, interaction: { items: ['oops'] } };
|
|
132
|
-
expect(validateEnvelope(bad).ok).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
it('rejects duplicate item id', () => {
|
|
135
|
-
const bad = {
|
|
136
|
-
...VALID_MIN_SPEC_GAP,
|
|
137
|
-
interaction: { items: [
|
|
138
|
-
{ id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
139
|
-
{ id: 'gap-001', status: 'open', answer: null, answeredBy: null, answeredAt: null, comments: [] },
|
|
140
|
-
] },
|
|
141
|
-
};
|
|
142
|
-
const r = validateEnvelope(bad);
|
|
143
|
-
expect(r.ok).toBe(false);
|
|
144
|
-
if (!r.ok) {
|
|
145
|
-
expect(r.error.code).toBe('INVALID_VALUE');
|
|
146
|
-
expect(r.error.field).toMatch(/interaction\.items\[1\]\.id/);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
it('rejects history entry with missing at', () => {
|
|
150
|
-
const bad = { ...VALID_MIN_SPEC_GAP,
|
|
151
|
-
history: [{ by: { name: 'L', role: 'consultant' }, action: 'created' }] };
|
|
152
|
-
const r = validateEnvelope(bad);
|
|
153
|
-
expect(r.ok).toBe(false);
|
|
154
|
-
if (!r.ok)
|
|
155
|
-
expect(r.error.code).toBe('INVALID_HISTORY');
|
|
156
|
-
});
|
|
157
|
-
it('rejects history entry with invalid action', () => {
|
|
158
|
-
const bad = { ...VALID_MIN_SPEC_GAP,
|
|
159
|
-
history: [{ at: '2026-05-04T10:00:00Z', by: { name: 'L', role: 'consultant' }, action: 'deleted' }] };
|
|
160
|
-
const r = validateEnvelope(bad);
|
|
161
|
-
expect(r.ok).toBe(false);
|
|
162
|
-
if (!r.ok)
|
|
163
|
-
expect(r.error.code).toBe('INVALID_HISTORY');
|
|
164
|
-
});
|
|
165
|
-
});
|