@dalzoubi/dev-agents-sync 2.0.6 → 2.0.8

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * tests/codex-target.test.mjs
3
+ *
4
+ * RED tests (slice: codex-writer-and-lockfile) — pin the codex target contract
5
+ * BEFORE implementation so that implement cannot accidentally skip a case.
6
+ *
7
+ * Contract:
8
+ * - TARGET_PREFIX.codex = '' (repo root — same strategy as "root" target).
9
+ * - FileMap keys carry the full repo-root-relative path: "codex/..." prefix.
10
+ * - resolveConsumerPath(root, 'codex/.agents/skills/define/SKILL.md')
11
+ * → <root>/.agents/skills/define/SKILL.md
12
+ * - resolveConsumerPath(root, 'codex/AGENTS.md') → <root>/AGENTS.md
13
+ * - normalizeFileMap PRESERVES 'codex/...' keys (currently drops them).
14
+ * - validateLockfile ACCEPTS targets containing 'codex' (currently rejects).
15
+ * - Backward-compat: legacy lockfiles (no codex) still validate + serialize
16
+ * byte-identically.
17
+ * - serializeLockfile sorts targets including 'codex'.
18
+ * - Schema: lockfile.schema.json does NOT exist locally; test documents this.
19
+ *
20
+ * Status markers per assertion (pre-implement):
21
+ * RED — expected to fail against current source until implement runs.
22
+ * GREEN — expected to pass against current source (backward compat / safety).
23
+ */
24
+
25
+ import { describe, it } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import path from 'node:path';
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ import { resolveConsumerPath, normalizeFileMap } from '../src/writer.mjs';
32
+ import {
33
+ validateLockfile,
34
+ serializeLockfile,
35
+ SCHEMA_URL,
36
+ } from '../src/lockfile.mjs';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Shared fixture — a minimal valid legacy lockfile (no codex)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const LEGACY_LOCKFILE = {
45
+ $schema: SCHEMA_URL,
46
+ source: 'github:dalzoubi/dev-agents',
47
+ range: '^1',
48
+ resolvedVersion: '1.0.0',
49
+ targets: ['claude', 'cursor'],
50
+ lastUpdated: '2026-04-24T00:00:00Z',
51
+ };
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // 1–2. resolveConsumerPath — codex target path translation
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe('resolveConsumerPath — codex target', () => {
58
+ const root = '/some/consumer';
59
+
60
+ // RED: 'codex' is not in TARGET_PREFIX — currently throws "unknown target prefix"
61
+ it('translates codex/.agents/skills/define/SKILL.md to <root>/.agents/skills/define/SKILL.md', () => {
62
+ const result = resolveConsumerPath(root, 'codex/.agents/skills/define/SKILL.md');
63
+ const expected = path.join(root, '.agents', 'skills', 'define', 'SKILL.md');
64
+ assert.equal(result, expected);
65
+ });
66
+
67
+ // RED: same root, different agent slug
68
+ it('translates codex/.agents/skills/implement/SKILL.md to <root>/.agents/skills/implement/SKILL.md', () => {
69
+ const result = resolveConsumerPath(root, 'codex/.agents/skills/implement/SKILL.md');
70
+ const expected = path.join(root, '.agents', 'skills', 'implement', 'SKILL.md');
71
+ assert.equal(result, expected);
72
+ });
73
+
74
+ // RED: third representative agent
75
+ it('translates codex/.agents/skills/test/SKILL.md to <root>/.agents/skills/test/SKILL.md', () => {
76
+ const result = resolveConsumerPath(root, 'codex/.agents/skills/test/SKILL.md');
77
+ const expected = path.join(root, '.agents', 'skills', 'test', 'SKILL.md');
78
+ assert.equal(result, expected);
79
+ });
80
+
81
+ // RED: repo-root file — codex/AGENTS.md → <root>/AGENTS.md (not nested under a subdir)
82
+ it('translates codex/AGENTS.md to <root>/AGENTS.md (repo root, not nested)', () => {
83
+ const result = resolveConsumerPath(root, 'codex/AGENTS.md');
84
+ const expected = path.join(root, 'AGENTS.md');
85
+ assert.equal(result, expected);
86
+ // Must NOT include any extra directory segment between root and AGENTS.md
87
+ const relative = path.relative(root, result);
88
+ assert.equal(relative, 'AGENTS.md', 'AGENTS.md must resolve directly under consumer root');
89
+ });
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // 3. normalizeFileMap — codex keys must be preserved, not dropped
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe('normalizeFileMap — codex/ keys', () => {
97
+ // RED: 'codex' is not in the recognized-prefix allow-list — currently dropped
98
+ it('preserves codex/.agents/skills/define/SKILL.md in normalized output', () => {
99
+ const out = normalizeFileMap({
100
+ 'codex/.agents/skills/define/SKILL.md': 'skill-content',
101
+ });
102
+ assert.equal(
103
+ out['codex/.agents/skills/define/SKILL.md'],
104
+ 'skill-content',
105
+ 'codex key must not be dropped by normalizeFileMap',
106
+ );
107
+ });
108
+
109
+ // RED: preserves codex/AGENTS.md too
110
+ it('preserves codex/AGENTS.md in normalized output', () => {
111
+ const out = normalizeFileMap({
112
+ 'codex/AGENTS.md': 'agents-content',
113
+ });
114
+ assert.equal(
115
+ out['codex/AGENTS.md'],
116
+ 'agents-content',
117
+ 'codex/AGENTS.md must not be dropped by normalizeFileMap',
118
+ );
119
+ });
120
+
121
+ // RED: does not warn for codex/ keys (parallel to root-target behavior)
122
+ it('does not emit a stderr warning for codex/ keys', () => {
123
+ const origWrite = process.stderr.write.bind(process.stderr);
124
+ let captured = '';
125
+ process.stderr.write = (chunk) => {
126
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
127
+ return true;
128
+ };
129
+ try {
130
+ normalizeFileMap({
131
+ 'codex/.agents/skills/define/SKILL.md': 'A',
132
+ 'codex/AGENTS.md': 'B',
133
+ });
134
+ assert.equal(
135
+ captured,
136
+ '',
137
+ `expected no stderr warning for recognized codex/ keys, got: ${JSON.stringify(captured)}`,
138
+ );
139
+ } finally {
140
+ process.stderr.write = origWrite;
141
+ }
142
+ });
143
+
144
+ // GREEN: an unrecognized prefix still gets dropped (the allow-list stays conservative)
145
+ it('still drops a genuinely unrecognized prefix (bogus/x.md)', () => {
146
+ const origWrite = process.stderr.write.bind(process.stderr);
147
+ let captured = '';
148
+ process.stderr.write = (chunk) => {
149
+ captured += typeof chunk === 'string' ? chunk : chunk.toString();
150
+ return true;
151
+ };
152
+ try {
153
+ const out = normalizeFileMap({
154
+ 'bogus/x.md': 'should-be-dropped',
155
+ 'codex/.agents/skills/define/SKILL.md': 'should-be-kept',
156
+ });
157
+ assert.equal(out['bogus/x.md'], undefined, 'bogus prefix must still be dropped');
158
+ assert.ok(captured.includes('warn:'), `expected stderr warn for bogus key, got: ${captured}`);
159
+ assert.ok(captured.includes("'bogus/x.md'"), `warn must name the dropped key, got: ${captured}`);
160
+ } finally {
161
+ process.stderr.write = origWrite;
162
+ }
163
+ });
164
+ });
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // 4. Path traversal — codex target must still enforce traversal safety
168
+ // ---------------------------------------------------------------------------
169
+
170
+ describe('resolveConsumerPath — traversal safety with codex prefix', () => {
171
+ const root = '/some/consumer';
172
+
173
+ // GREEN: traversal within any prefix must be rejected regardless of the prefix name
174
+ // (This should already pass once codex is in TARGET_PREFIX, because the
175
+ // traversal check runs before the TARGET_PREFIX lookup. We assert it here
176
+ // so the impl can never regress on this.)
177
+ it('throws on path traversal via codex/../../etc/passwd', () => {
178
+ assert.throws(
179
+ () => resolveConsumerPath(root, 'codex/../../etc/passwd'),
180
+ /traversal|invalid|outside/i,
181
+ 'traversal via codex prefix must be rejected',
182
+ );
183
+ });
184
+
185
+ it('throws on path traversal via codex/../../../etc/passwd', () => {
186
+ assert.throws(
187
+ () => resolveConsumerPath(root, 'codex/../../../etc/passwd'),
188
+ /traversal|invalid|outside/i,
189
+ );
190
+ });
191
+ });
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // 5. validateLockfile — accepts targets containing 'codex'
195
+ // ---------------------------------------------------------------------------
196
+
197
+ describe('validateLockfile — codex in targets', () => {
198
+ // RED: VALID_TARGETS does not include 'codex' — currently throws
199
+ it('accepts targets: ["codex"]', () => {
200
+ assert.doesNotThrow(
201
+ () =>
202
+ validateLockfile({
203
+ ...LEGACY_LOCKFILE,
204
+ targets: ['codex'],
205
+ }),
206
+ 'validateLockfile must accept a codex-only targets array',
207
+ );
208
+ });
209
+
210
+ // RED: mixed targets including codex
211
+ it('accepts targets: ["claude", "codex"]', () => {
212
+ assert.doesNotThrow(
213
+ () =>
214
+ validateLockfile({
215
+ ...LEGACY_LOCKFILE,
216
+ targets: ['claude', 'codex'],
217
+ }),
218
+ 'validateLockfile must accept ["claude", "codex"]',
219
+ );
220
+ });
221
+
222
+ // RED: all known targets including codex
223
+ it('accepts targets: ["claude", "codex", "copilot", "cursor"]', () => {
224
+ assert.doesNotThrow(
225
+ () =>
226
+ validateLockfile({
227
+ ...LEGACY_LOCKFILE,
228
+ targets: ['claude', 'codex', 'copilot', 'cursor'],
229
+ }),
230
+ 'validateLockfile must accept the full set of valid targets',
231
+ );
232
+ });
233
+
234
+ // GREEN: genuinely unknown targets still rejected (conservative allow-list)
235
+ it('still rejects an unknown target value (e.g. "vscode")', () => {
236
+ assert.throws(
237
+ () =>
238
+ validateLockfile({
239
+ ...LEGACY_LOCKFILE,
240
+ targets: ['claude', 'vscode'],
241
+ }),
242
+ /targets/i,
243
+ 'non-codex unknown targets must still be rejected',
244
+ );
245
+ });
246
+ });
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // 6. Backward-compat: legacy lockfile (no codex) still validates and serializes
250
+ // byte-identically — no new fields introduced, field order unchanged.
251
+ // ---------------------------------------------------------------------------
252
+
253
+ describe('backward-compat — legacy lockfile round-trip', () => {
254
+ // GREEN: existing consumers not using codex must be unaffected
255
+ it('validateLockfile still accepts a legacy lockfile with targets: ["claude"]', () => {
256
+ assert.doesNotThrow(
257
+ () =>
258
+ validateLockfile({
259
+ ...LEGACY_LOCKFILE,
260
+ targets: ['claude'],
261
+ }),
262
+ );
263
+ });
264
+
265
+ // GREEN: serialization of a legacy lockfile must be byte-identical before and after
266
+ // the codex change — FIELD_ORDER must not gain new entries, no manifest key added.
267
+ it('serializeLockfile of a legacy lockfile matches exact expected JSON (FIELD_ORDER lock)', () => {
268
+ const expected =
269
+ '{\n' +
270
+ ` "$schema": "${SCHEMA_URL}",\n` +
271
+ ' "source": "github:dalzoubi/dev-agents",\n' +
272
+ ' "range": "^1",\n' +
273
+ ' "resolvedVersion": "1.0.0",\n' +
274
+ ' "targets": [\n' +
275
+ ' "claude",\n' +
276
+ ' "cursor"\n' +
277
+ ' ],\n' +
278
+ ' "lastUpdated": "2026-04-24T00:00:00Z"\n' +
279
+ '}\n';
280
+
281
+ const actual = serializeLockfile(LEGACY_LOCKFILE);
282
+ assert.equal(
283
+ actual,
284
+ expected,
285
+ 'serialized legacy lockfile must be byte-identical to expected (field order + no extra fields)',
286
+ );
287
+ });
288
+
289
+ // GREEN: no new top-level keys appear in the serialized output
290
+ it('serialized legacy lockfile does not contain a "codex" key at any level', () => {
291
+ const serialized = serializeLockfile(LEGACY_LOCKFILE);
292
+ const parsed = JSON.parse(serialized);
293
+ assert.ok(
294
+ !Object.keys(parsed).includes('codex'),
295
+ 'serialized lockfile must not have a top-level "codex" key',
296
+ );
297
+ });
298
+ });
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // 7. serializeLockfile — sorts targets including 'codex'
302
+ // ---------------------------------------------------------------------------
303
+
304
+ describe('serializeLockfile — target sorting with codex', () => {
305
+ // RED: if validateLockfile rejects codex, serializeLockfile would never be
306
+ // reached. Once implement adds codex to VALID_TARGETS, these assertions
307
+ // confirm the sort order is correct.
308
+ it('sorts ["codex","claude"] → ["claude","codex"] in serialized output', () => {
309
+ const lock = { ...LEGACY_LOCKFILE, targets: ['codex', 'claude'] };
310
+ // Bypass validateLockfile — we're testing serializeLockfile's sort in isolation.
311
+ // serializeLockfile does not call validateLockfile internally.
312
+ const serialized = serializeLockfile(lock);
313
+ const parsed = JSON.parse(serialized);
314
+ assert.deepEqual(
315
+ parsed.targets,
316
+ ['claude', 'codex'],
317
+ 'targets must be sorted alphabetically',
318
+ );
319
+ });
320
+
321
+ it('sorts ["cursor","codex","claude"] → ["claude","codex","cursor"]', () => {
322
+ const lock = { ...LEGACY_LOCKFILE, targets: ['cursor', 'codex', 'claude'] };
323
+ const serialized = serializeLockfile(lock);
324
+ const parsed = JSON.parse(serialized);
325
+ assert.deepEqual(parsed.targets, ['claude', 'codex', 'cursor']);
326
+ });
327
+
328
+ // GREEN: existing sort behavior for targets without codex is unchanged
329
+ it('sorts ["cursor","claude"] → ["claude","cursor"] (regression — no codex)', () => {
330
+ const lock = { ...LEGACY_LOCKFILE, targets: ['cursor', 'claude'] };
331
+ const serialized = serializeLockfile(lock);
332
+ const parsed = JSON.parse(serialized);
333
+ assert.deepEqual(parsed.targets, ['claude', 'cursor']);
334
+ });
335
+ });
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // 8. Schema: lockfile.schema.json local presence + enum check
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe('lockfile schema — local file presence and enum constraint', () => {
342
+ // This test documents the schema state so the implementer knows whether a
343
+ // schema edit is required as part of the codex slice.
344
+ //
345
+ // Current finding: schema/lockfile.schema.json does NOT exist in this repo.
346
+ // The SCHEMA_URL in lockfile.mjs points to a remote file at the published
347
+ // GitHub raw URL. No local enum validation is enforced by the CLI's own code.
348
+ // The implementer does NOT need to create/update a local schema file for the
349
+ // codex target change (VALID_TARGETS in lockfile.mjs is the authoritative
350
+ // allow-list). If a local schema file is introduced in the future, it must
351
+ // include 'codex' in the targets enum.
352
+
353
+ it('schema/lockfile.schema.json does NOT exist locally (remote schema only)', () => {
354
+ const schemaPath = path.join(__dirname, '..', '..', '..', 'schema', 'lockfile.schema.json');
355
+ assert.equal(
356
+ existsSync(schemaPath),
357
+ false,
358
+ [
359
+ 'schema/lockfile.schema.json must not exist locally.',
360
+ 'If it does exist, add "codex" to its targets enum.',
361
+ `Checked path: ${schemaPath}`,
362
+ ].join(' '),
363
+ );
364
+ });
365
+
366
+ // GREEN (informational): the SCHEMA_URL constant points to a versioned remote URL.
367
+ // It must not be empty, and must reference the known repo path.
368
+ it('SCHEMA_URL is a non-empty string referencing the dalzoubi/dev-agents repo', () => {
369
+ assert.ok(typeof SCHEMA_URL === 'string' && SCHEMA_URL.length > 0, 'SCHEMA_URL must be a non-empty string');
370
+ assert.ok(
371
+ SCHEMA_URL.includes('dalzoubi/dev-agents'),
372
+ `SCHEMA_URL must reference the dalzoubi/dev-agents repo, got: ${SCHEMA_URL}`,
373
+ );
374
+ });
375
+ });