1/**
2 * Tests for critique snapshot persistence.
3 * Run with: node --test tests/critique-storage.test.mjs
4 */
5
6import { describe, it, beforeEach, afterEach } from 'node:test';
7import assert from 'node:assert/strict';
8import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
9import { join } from 'node:path';
10import { tmpdir } from 'node:os';
11import { spawnSync } from 'node:child_process';
12import { fileURLToPath } from 'node:url';
13
14const SCRIPT = fileURLToPath(new URL('../skill/scripts/critique-storage.mjs', import.meta.url));
15
16import {
17 slugFromTarget,
18 writeSnapshot,
19 readLatestSnapshot,
20 readTrend,
21 nowFilenameStamp,
22} from '../skill/scripts/critique-storage.mjs';
23
24let cwd;
25beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'imp-critique-')); });
26afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });
27
28describe('slugFromTarget', () => {
29 it('kebabs a relative file path', () => {
30 assert.equal(slugFromTarget('site/pages/index.astro', { cwd }), 'site-pages-index-astro');
31 });
32
33 it('kebabs an absolute path inside cwd by relativizing', () => {
34 const abs = join(cwd, 'site/pages/index.astro');
35 assert.equal(slugFromTarget(abs, { cwd }), 'site-pages-index-astro');
36 });
37
38 it('uses basename for absolute paths outside cwd', () => {
39 // Sibling path, not under cwd
40 const abs = join(tmpdir(), 'somewhere', 'else', 'page.html');
41 assert.equal(slugFromTarget(abs, { cwd }), 'page-html');
42 });
43
44 it('drops port from URL', () => {
45 assert.equal(slugFromTarget('http://localhost:3000/pricing', { cwd }), 'localhost-pricing');
46 });
47
48 it('normalizes URL casing and trailing slash', () => {
49 assert.equal(
50 slugFromTarget('https://Impeccable.Style/docs/audit/', { cwd }),
51 'impeccable-style-docs-audit',
52 );
53 });
54
55 it('strips query strings', () => {
56 assert.equal(
57 slugFromTarget('https://example.com/x?utm=1&foo=bar', { cwd }),
58 'example-com-x',
59 );
60 });
61
62 it('returns null for empty / project-root inputs', () => {
63 assert.equal(slugFromTarget('', { cwd }), null);
64 assert.equal(slugFromTarget('.', { cwd }), null);
65 assert.equal(slugFromTarget(null, { cwd }), null);
66 });
67
68 it('caps overly long slugs from the tail', () => {
69 const longPath = 'a/'.repeat(60) + 'file.tsx'; // way over 50
70 const slug = slugFromTarget(longPath, { cwd });
71 assert.ok(slug.length <= 50);
72 assert.ok(slug.endsWith('file-tsx'));
73 });
74
75 it('is stable: same input → same slug', () => {
76 const a = slugFromTarget('site/pages/index.astro', { cwd });
77 const b = slugFromTarget('site/pages/index.astro', { cwd });
78 assert.equal(a, b);
79 });
80});
81
82describe('nowFilenameStamp', () => {
83 it('is windows-safe (no colons or dots in the time fragment)', () => {
84 const stamp = nowFilenameStamp(new Date('2026-05-12T18:30:00.123Z'));
85 assert.equal(stamp, '2026-05-12T18-30-00Z');
86 });
87});
88
89describe('writeSnapshot + readLatestSnapshot', () => {
90 it('round-trips body and frontmatter', () => {
91 const out = writeSnapshot({
92 slug: 'index-astro',
93 meta: { target: 'the homepage', total_score: 28, p0_count: 1, p1_count: 3 },
94 body: '# Critique\n\nP0: nested cards',
95 cwd,
96 });
97 assert.ok(out.endsWith('__index-astro.md'));
98 const latest = readLatestSnapshot('index-astro', { cwd });
99 assert.equal(latest.meta.slug, 'index-astro');
100 assert.equal(latest.meta.target, 'the homepage');
101 assert.equal(latest.meta.total_score, 28);
102 assert.match(latest.body, /P0: nested cards/);
103 });
104
105 it('returns null when no snapshot for slug', () => {
106 assert.equal(readLatestSnapshot('nope', { cwd }), null);
107 });
108
109 it('picks the newest by filename when multiple exist', () => {
110 writeSnapshot({ slug: 'index-astro', meta: { total_score: 22 }, body: 'old', cwd, now: new Date('2026-05-01T00:00:00Z') });
111 writeSnapshot({ slug: 'index-astro', meta: { total_score: 30 }, body: 'new', cwd, now: new Date('2026-05-12T00:00:00Z') });
112 const latest = readLatestSnapshot('index-astro', { cwd });
113 assert.equal(latest.meta.total_score, 30);
114 assert.match(latest.body, /new/);
115 });
116
117 it('does not see snapshots for a different slug', () => {
118 writeSnapshot({ slug: 'pricing-astro', meta: { total_score: 10 }, body: 'b', cwd });
119 assert.equal(readLatestSnapshot('index-astro', { cwd }), null);
120 });
121
122 it('caller-supplied meta cannot override computed timestamp or slug', () => {
123 // Defends against a corrupt IMPECCABLE_CRITIQUE_META blob (parsed from
124 // an env var) silently rewriting fields that must agree with the
125 // filename. Otherwise readTrend would attribute scores to the wrong
126 // timestamps with no error.
127 const out = writeSnapshot({
128 slug: 'index-astro',
129 meta: { timestamp: 'NOT_A_REAL_STAMP', slug: 'somewhere-else', total_score: 50 },
130 body: 'b',
131 cwd,
132 now: new Date('2026-05-12T18:30:00Z'),
133 });
134 const latest = readLatestSnapshot('index-astro', { cwd });
135 assert.equal(latest.meta.slug, 'index-astro');
136 assert.equal(latest.meta.timestamp, '2026-05-12T18-30-00Z');
137 // The legit meta field still lands.
138 assert.equal(latest.meta.total_score, 50);
139 // The filename matches the computed slug.
140 assert.ok(out.endsWith('2026-05-12T18-30-00Z__index-astro.md'));
141 });
142
143 it('quotes values containing : or # to keep parsing simple', () => {
144 writeSnapshot({
145 slug: 'x',
146 meta: { target: 'docs: critique # main' },
147 body: '...',
148 cwd,
149 });
150 const latest = readLatestSnapshot('x', { cwd });
151 assert.equal(latest.meta.target, 'docs: critique # main');
152 });
153});
154
155describe('CLI entry point', () => {
156 // Why a subprocess test: the CLI guard at the bottom of the script
157 // previously compared import.meta.url to `file://${process.argv[1]}`,
158 // which silently broke on Windows (forward vs back slashes) — exit 0,
159 // no output, save skipped. The exported functions kept passing because
160 // tests never spawned the script as a process. See issue #155.
161 it('slug subcommand prints a slug and exits 0', () => {
162 const r = spawnSync(process.execPath, [SCRIPT, 'slug', 'site/pages/index.astro'], {
163 cwd,
164 encoding: 'utf-8',
165 });
166 assert.equal(r.status, 0, `stderr: ${r.stderr}`);
167 assert.equal(r.stdout.trim(), 'site-pages-index-astro');
168 });
169
170 it('slug subcommand exits 1 with a message for empty input', () => {
171 const r = spawnSync(process.execPath, [SCRIPT, 'slug', ''], { cwd, encoding: 'utf-8' });
172 assert.equal(r.status, 1);
173 assert.match(r.stderr, /no stable slug/);
174 });
175
176 it('runs when invoked through a symlinked harness path', () => {
177 const linkedScript = join(cwd, 'linked-critique-storage.mjs');
178 symlinkSync(SCRIPT, linkedScript);
179
180 const r = spawnSync(process.execPath, [linkedScript, 'slug', 'index.html'], {
181 cwd,
182 encoding: 'utf-8',
183 });
184
185 assert.equal(r.status, 0, `stderr: ${r.stderr}`);
186 assert.equal(r.stdout.trim(), 'index-html');
187 });
188
189 it('latest subcommand exits 2 when no snapshot exists', () => {
190 const r = spawnSync(process.execPath, [SCRIPT, 'latest', 'never-written'], {
191 cwd,
192 encoding: 'utf-8',
193 });
194 assert.equal(r.status, 2);
195 });
196});
197
198describe('readTrend', () => {
199 it('returns last N entries oldest → newest, filtered by slug', () => {
200 for (let i = 0; i < 6; i++) {
201 writeSnapshot({
202 slug: 'index-astro',
203 meta: { total_score: 20 + i },
204 body: `run ${i}`,
205 cwd,
206 now: new Date(2026, 4, i + 1),
207 });
208 }
209 writeSnapshot({ slug: 'pricing-astro', meta: { total_score: 99 }, body: 'unrelated', cwd });
210 const trend = readTrend('index-astro', { limit: 5, cwd });
211 assert.equal(trend.length, 5);
212 assert.equal(trend[0].total_score, 21); // dropped the oldest
213 assert.equal(trend[4].total_score, 25);
214 });
215
216 it('returns empty when no snapshots', () => {
217 assert.deepEqual(readTrend('nope', { cwd }), []);
218 });
219});