cli.test.mjs
301 lines 10.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * CLI tests: exit codes and JSON output for list-notes, get-note (with fixture vault).
3 */
4 import { describe, it } from 'node:test';
5 import assert from 'node:assert';
6 import fs from 'fs';
7 import os from 'os';
8 import path from 'path';
9 import { execFileSync } from 'child_process';
10 import { fileURLToPath } from 'url';
11
12 const __dirname = path.dirname(fileURLToPath(import.meta.url));
13 const projectRoot = path.resolve(__dirname, '..');
14 const cliPath = path.join(projectRoot, 'cli', 'index.mjs');
15 const fixtureVault = path.join(__dirname, 'fixtures', 'vault-fs');
16 const isolatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-cli-test-'));
17
18 function cliEnv(env = {}) {
19 return {
20 ...process.env,
21 NETLIFY: '1',
22 KNOWTATION_VAULT_PATH: fixtureVault,
23 ...env,
24 };
25 }
26
27 function runCli(args, env = {}) {
28 return execFileSync(process.execPath, [cliPath, ...args], {
29 encoding: 'utf8',
30 cwd: isolatedCwd,
31 env: cliEnv(env),
32 });
33 }
34
35 function runCliExitCode(args, env = {}) {
36 try {
37 runCli(args, env);
38 return 0;
39 } catch (e) {
40 return e.status ?? e.code ?? 1;
41 }
42 }
43
44 describe('CLI', () => {
45 describe('list-notes', () => {
46 it('exits 0 with --json and outputs valid JSON with notes and total', () => {
47 const out = runCli(['list-notes', '--limit', '2', '--json']);
48 const data = JSON.parse(out);
49 assert(Array.isArray(data.notes));
50 assert(typeof data.total === 'number');
51 assert(data.notes.length <= 2);
52 });
53
54 it('--count-only --json outputs only total', () => {
55 const out = runCli(['list-notes', '--count-only', '--json']);
56 const data = JSON.parse(out);
57 assert(typeof data.total === 'number');
58 assert.strictEqual(data.notes, undefined);
59 });
60 });
61
62 describe('get-note', () => {
63 it('exits 0 with --json and outputs path, frontmatter, body', () => {
64 const out = runCli(['get-note', 'inbox/one.md', '--json']);
65 const data = JSON.parse(out);
66 assert.strictEqual(data.path, 'inbox/one.md');
67 assert(data.body && data.body.includes('Inbox one'));
68 assert(typeof data.frontmatter === 'object');
69 });
70
71 it('exits non-zero for missing note', () => {
72 const code = runCliExitCode(['get-note', 'inbox/nonexistent.md', '--json']);
73 assert(code !== 0);
74 });
75 });
76
77 describe('get-note-outline', () => {
78 it('exits 0 with --json and outputs the NoteOutline contract', () => {
79 const out = runCli(['get-note-outline', 'inbox/one.md', '--json']);
80 const data = JSON.parse(out);
81 assert.strictEqual(data.schema, 'knowtation.note_outline/v1');
82 assert.strictEqual(data.path, 'inbox/one.md');
83 assert.strictEqual(data.title, 'one');
84 assert.deepStrictEqual(data.headings, [
85 { level: 1, text: 'Inbox one', id: 'h1-inbox-one-0001' },
86 ]);
87 assert.strictEqual(data.truncated, false);
88 });
89
90 it('does not include body, snippets, frontmatter, or absolute paths', () => {
91 const out = runCli(['get-note-outline', 'inbox/one.md', '--json']);
92 const data = JSON.parse(out);
93 const serialized = JSON.stringify(data);
94 assert.strictEqual(Object.hasOwn(data, 'body'), false);
95 assert.strictEqual(Object.hasOwn(data, 'snippet'), false);
96 assert.strictEqual(Object.hasOwn(data, 'frontmatter'), false);
97 assert.strictEqual(serialized.includes('Body of inbox one'), false);
98 assert.strictEqual(serialized.includes('/Users/'), false);
99 });
100
101 it('exits non-zero for missing note', () => {
102 const code = runCliExitCode(['get-note-outline', 'inbox/nonexistent.md', '--json']);
103 assert.notStrictEqual(code, 0);
104 });
105
106 it('exits non-zero for traversal paths', () => {
107 const code = runCliExitCode(['get-note-outline', '../../../etc/passwd', '--json']);
108 assert.notStrictEqual(code, 0);
109 });
110 });
111
112 describe('get-document-tree', () => {
113 it('exits 0 with --json and outputs the DocumentTree v0 contract', () => {
114 const out = runCli(['get-document-tree', 'inbox/one.md', '--json']);
115 const data = JSON.parse(out);
116 assert.strictEqual(data.schema, 'knowtation.document_tree/v0');
117 assert.strictEqual(data.path, 'inbox/one.md');
118 assert.strictEqual(data.title, 'one');
119 assert.deepStrictEqual(data.root, {
120 children: [
121 {
122 id: 'h1-inbox-one-0001',
123 level: 1,
124 text: 'Inbox one',
125 children: [],
126 },
127 ],
128 });
129 assert.strictEqual(data.truncated, false);
130 });
131
132 it('does not include body, snippets, frontmatter, absolute paths, or summaries', () => {
133 const out = runCli(['get-document-tree', 'inbox/one.md', '--json']);
134 const data = JSON.parse(out);
135 const serialized = JSON.stringify(data);
136 assert.strictEqual(Object.hasOwn(data, 'body'), false);
137 assert.strictEqual(Object.hasOwn(data, 'snippet'), false);
138 assert.strictEqual(Object.hasOwn(data, 'frontmatter'), false);
139 assert.strictEqual(Object.hasOwn(data, 'summary'), false);
140 assert.strictEqual(serialized.includes('Body of inbox one'), false);
141 assert.strictEqual(serialized.includes('/Users/'), false);
142 });
143
144 it('exits non-zero when --json is missing', () => {
145 const code = runCliExitCode(['get-document-tree', 'inbox/one.md']);
146 assert.notStrictEqual(code, 0);
147 });
148
149 it('exits non-zero for missing note', () => {
150 const code = runCliExitCode(['get-document-tree', 'inbox/nonexistent.md', '--json']);
151 assert.notStrictEqual(code, 0);
152 });
153
154 it('exits non-zero for traversal paths', () => {
155 const code = runCliExitCode(['get-document-tree', '../../../etc/passwd', '--json']);
156 assert.notStrictEqual(code, 0);
157 });
158 });
159
160 describe('get-metadata-facets', () => {
161 it('exits 0 with --json and outputs the MetadataFacets v0 contract', () => {
162 const out = runCli(['get-metadata-facets', 'inbox/one.md', '--json']);
163 const data = JSON.parse(out);
164 assert.deepStrictEqual(data, {
165 schema: 'knowtation.metadata_facets/v0',
166 path: 'inbox/one.md',
167 facets: {
168 project: 'foo',
169 tags: ['a', 'b'],
170 date: '2025-03-01T00:00:00.000Z',
171 updated: null,
172 causal_chain_id: null,
173 entity: [],
174 episode_id: null,
175 },
176 inferred: {
177 folder: 'inbox',
178 source_type: null,
179 },
180 truncated: false,
181 });
182 });
183
184 it('does not include body, snippets, full frontmatter, or absolute paths', () => {
185 const out = runCli(['get-metadata-facets', 'inbox/one.md', '--json']);
186 const data = JSON.parse(out);
187 const serialized = JSON.stringify(data);
188 assert.strictEqual(Object.hasOwn(data, 'body'), false);
189 assert.strictEqual(Object.hasOwn(data, 'snippet'), false);
190 assert.strictEqual(Object.hasOwn(data, 'frontmatter'), false);
191 assert.strictEqual(Object.hasOwn(data, 'summary'), false);
192 assert.strictEqual(serialized.includes('Body of inbox one'), false);
193 assert.strictEqual(serialized.includes('/Users/'), false);
194 });
195
196 it('exits non-zero when --json is missing', () => {
197 const code = runCliExitCode(['get-metadata-facets', 'inbox/one.md']);
198 assert.notStrictEqual(code, 0);
199 });
200
201 it('exits non-zero for missing note', () => {
202 const code = runCliExitCode(['get-metadata-facets', 'inbox/nonexistent.md', '--json']);
203 assert.notStrictEqual(code, 0);
204 });
205
206 it('exits non-zero for traversal paths', () => {
207 const code = runCliExitCode(['get-metadata-facets', '../../../etc/passwd', '--json']);
208 assert.notStrictEqual(code, 0);
209 });
210 });
211
212 describe('get-section-source', () => {
213 it('exits 0 with --json and outputs the SectionSource v0 contract', () => {
214 const out = runCli(['get-section-source', 'inbox/one.md', '--json']);
215 const data = JSON.parse(out);
216
217 assert.deepStrictEqual(data, {
218 schema: 'knowtation.section_source/v0',
219 path: 'inbox/one.md',
220 title: 'one',
221 sections: [
222 {
223 section_id: 'inbox-one-md:h1-inbox-one-0001',
224 heading_id: 'h1-inbox-one-0001',
225 level: 1,
226 heading_path: ['Inbox one'],
227 heading_text: 'Inbox one',
228 child_section_ids: [],
229 body_available: true,
230 body_returned: false,
231 snippet_returned: false,
232 },
233 ],
234 truncated: false,
235 });
236 });
237
238 it('does not include body, snippets, full frontmatter, absolute paths, or transport metadata', () => {
239 const out = runCli(['get-section-source', 'inbox/one.md', '--json']);
240 const data = JSON.parse(out);
241 const serialized = JSON.stringify(data);
242
243 assert.strictEqual(Object.hasOwn(data, 'body'), false);
244 assert.strictEqual(Object.hasOwn(data, 'snippet'), false);
245 assert.strictEqual(Object.hasOwn(data, 'frontmatter'), false);
246 assert.strictEqual(Object.hasOwn(data, 'summary'), false);
247 assert.strictEqual(Object.hasOwn(data, 'resource_uri'), false);
248 assert.strictEqual(serialized.includes('Body of inbox one'), false);
249 assert.strictEqual(serialized.includes('/Users/'), false);
250 assert.strictEqual(serialized.includes('knowtation://'), false);
251 });
252
253 it('exits non-zero when --json is missing', () => {
254 const code = runCliExitCode(['get-section-source', 'inbox/one.md']);
255 assert.notStrictEqual(code, 0);
256 });
257
258 it('exits non-zero when more than one path is supplied', () => {
259 const code = runCliExitCode(['get-section-source', 'inbox/one.md', 'inbox/two.md', '--json']);
260 assert.notStrictEqual(code, 0);
261 });
262
263 it('exits non-zero for missing note', () => {
264 const code = runCliExitCode(['get-section-source', 'inbox/nonexistent.md', '--json']);
265 assert.notStrictEqual(code, 0);
266 });
267
268 it('exits non-zero for traversal paths', () => {
269 const code = runCliExitCode(['get-section-source', '../../../etc/passwd', '--json']);
270 assert.notStrictEqual(code, 0);
271 });
272 });
273
274 describe('help', () => {
275 it('--help exits 0', () => {
276 const code = runCliExitCode(['--help']);
277 assert.strictEqual(code, 0);
278 });
279 });
280
281 describe('doctor', () => {
282 it('--json exits 0 with ok and token_layers', () => {
283 const env = cliEnv();
284 delete env.KNOWTATION_HUB_URL;
285 delete env.KNOWTATION_HUB_TOKEN;
286 delete env.KNOWTATION_HUB_VAULT_ID;
287 const out = execFileSync(process.execPath, [cliPath, 'doctor', '--json'], {
288 encoding: 'utf8',
289 cwd: isolatedCwd,
290 env,
291 });
292 const data = JSON.parse(out);
293 assert.strictEqual(typeof data.ok, 'boolean');
294 assert.ok(data.token_layers);
295 assert.ok(data.token_layers.vault_retrieval);
296 assert.ok(data.token_layers.terminal_tooling);
297 assert.strictEqual(data.self_hosted.config_loaded, true);
298 assert.strictEqual(data.self_hosted.vault_readable, true);
299 });
300 });
301 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago