fe-harness 1.0.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/README.md +55 -0
- package/agents/fe-codebase-mapper.md +945 -0
- package/agents/fe-design-scanner.md +47 -0
- package/agents/fe-executor.md +221 -0
- package/agents/fe-fix-loop.md +310 -0
- package/agents/fe-fixer.md +153 -0
- package/agents/fe-project-scanner.md +95 -0
- package/agents/fe-reviewer.md +141 -0
- package/agents/fe-verifier.md +231 -0
- package/agents/fe-wave-runner.md +477 -0
- package/bin/install.js +292 -0
- package/commands/fe/complete.md +35 -0
- package/commands/fe/execute.md +46 -0
- package/commands/fe/help.md +17 -0
- package/commands/fe/map-codebase.md +60 -0
- package/commands/fe/plan.md +36 -0
- package/commands/fe/status.md +39 -0
- package/fe-harness/bin/browser.cjs +271 -0
- package/fe-harness/bin/fe-tools.cjs +317 -0
- package/fe-harness/bin/lib/__tests__/browser.test.cjs +422 -0
- package/fe-harness/bin/lib/__tests__/config.test.cjs +93 -0
- package/fe-harness/bin/lib/__tests__/core.test.cjs +127 -0
- package/fe-harness/bin/lib/__tests__/scoring.test.cjs +130 -0
- package/fe-harness/bin/lib/__tests__/tasks.test.cjs +698 -0
- package/fe-harness/bin/lib/browser-core.cjs +365 -0
- package/fe-harness/bin/lib/config.cjs +34 -0
- package/fe-harness/bin/lib/core.cjs +135 -0
- package/fe-harness/bin/lib/logger.cjs +93 -0
- package/fe-harness/bin/lib/scoring.cjs +219 -0
- package/fe-harness/bin/lib/tasks.cjs +632 -0
- package/fe-harness/references/model-profiles.md +44 -0
- package/fe-harness/templates/config.jsonc +31 -0
- package/fe-harness/vendor/.gitkeep +0 -0
- package/fe-harness/vendor/puppeteer-core.cjs +445 -0
- package/fe-harness/workflows/complete.md +143 -0
- package/fe-harness/workflows/execute.md +227 -0
- package/fe-harness/workflows/help.md +89 -0
- package/fe-harness/workflows/map-codebase.md +331 -0
- package/fe-harness/workflows/plan.md +244 -0
- package/package.json +35 -0
- package/scripts/bundle-puppeteer.js +38 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, before, after, beforeEach, afterEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execSync, spawn } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const browserCjs = path.resolve(__dirname, '..', '..', 'browser.cjs');
|
|
11
|
+
const {
|
|
12
|
+
findChrome,
|
|
13
|
+
generateSessionId,
|
|
14
|
+
readSession,
|
|
15
|
+
writeSession,
|
|
16
|
+
deleteSession,
|
|
17
|
+
listSessions,
|
|
18
|
+
formatA11yNode,
|
|
19
|
+
} = require('../browser-core.cjs');
|
|
20
|
+
|
|
21
|
+
// --- Helper ---
|
|
22
|
+
|
|
23
|
+
function run(args, opts = {}) {
|
|
24
|
+
const timeout = opts.timeout || 20000;
|
|
25
|
+
const result = execSync(`node ${browserCjs} ${args}`, {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout,
|
|
28
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
29
|
+
}).trim();
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(result);
|
|
32
|
+
// Don't parse plain numbers/booleans — they're likely session IDs
|
|
33
|
+
if (typeof parsed === 'number' || typeof parsed === 'boolean') return result;
|
|
34
|
+
return parsed;
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// Unit tests (no browser needed)
|
|
42
|
+
// ============================================================
|
|
43
|
+
|
|
44
|
+
describe('browser-core unit tests', () => {
|
|
45
|
+
|
|
46
|
+
describe('findChrome', () => {
|
|
47
|
+
it('should find Chrome on this machine', () => {
|
|
48
|
+
const chromePath = findChrome();
|
|
49
|
+
assert.ok(chromePath, 'Chrome should be found');
|
|
50
|
+
assert.ok(fs.existsSync(chromePath), `Chrome path should exist: ${chromePath}`);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('generateSessionId', () => {
|
|
55
|
+
it('should return a 6-char hex string', () => {
|
|
56
|
+
const id = generateSessionId();
|
|
57
|
+
assert.match(id, /^[0-9a-f]{6}$/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return unique values', () => {
|
|
61
|
+
const ids = new Set(Array.from({ length: 20 }, () => generateSessionId()));
|
|
62
|
+
assert.ok(ids.size >= 18, 'Should generate mostly unique IDs');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('session file management', () => {
|
|
67
|
+
let testSessionId;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
testSessionId = 'test' + generateSessionId().slice(0, 2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
deleteSession(testSessionId);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('readSession returns null for non-existent session', () => {
|
|
78
|
+
assert.equal(readSession('nonexistent999'), null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('writeSession + readSession roundtrip', () => {
|
|
82
|
+
const data = { pid: 12345, wsEndpoint: 'ws://localhost:9222', createdAt: new Date().toISOString() };
|
|
83
|
+
writeSession(testSessionId, data);
|
|
84
|
+
const loaded = readSession(testSessionId);
|
|
85
|
+
assert.deepEqual(loaded, data);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('deleteSession removes session file', () => {
|
|
89
|
+
writeSession(testSessionId, { pid: 1 });
|
|
90
|
+
assert.ok(readSession(testSessionId));
|
|
91
|
+
deleteSession(testSessionId);
|
|
92
|
+
assert.equal(readSession(testSessionId), null);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('formatA11yNode', () => {
|
|
97
|
+
it('should format a simple node', () => {
|
|
98
|
+
const result = formatA11yNode({ role: 'button', name: 'Submit' });
|
|
99
|
+
assert.equal(result.trim(), '- button "Submit"');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle nested children', () => {
|
|
103
|
+
const tree = {
|
|
104
|
+
role: 'document',
|
|
105
|
+
name: 'Page',
|
|
106
|
+
children: [
|
|
107
|
+
{ role: 'heading', name: 'Title', level: 1 },
|
|
108
|
+
{ role: 'button', name: 'Click' },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
const result = formatA11yNode(tree);
|
|
112
|
+
assert.ok(result.includes('- document "Page"'));
|
|
113
|
+
assert.ok(result.includes(' - heading "Title" level=1'));
|
|
114
|
+
assert.ok(result.includes(' - button "Click"'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle null input', () => {
|
|
118
|
+
assert.equal(formatA11yNode(null), '');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('CLI usage', () => {
|
|
123
|
+
it('should show usage for unknown command', () => {
|
|
124
|
+
try {
|
|
125
|
+
run('unknowncmd');
|
|
126
|
+
assert.fail('Should have thrown');
|
|
127
|
+
} catch (err) {
|
|
128
|
+
assert.ok(err.message.includes('Unknown command') || err.status === 1);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ============================================================
|
|
135
|
+
// Integration tests (launch real Chrome)
|
|
136
|
+
// ============================================================
|
|
137
|
+
|
|
138
|
+
describe('browser.cjs integration tests', () => {
|
|
139
|
+
|
|
140
|
+
describe('start command', () => {
|
|
141
|
+
let sessionId;
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
if (sessionId) {
|
|
145
|
+
try { run(`stop ${sessionId}`); } catch (_) {}
|
|
146
|
+
sessionId = null;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should start a browser and return session info', () => {
|
|
151
|
+
const result = run('start');
|
|
152
|
+
assert.equal(result.ok, true);
|
|
153
|
+
assert.ok(result.sessionId);
|
|
154
|
+
assert.ok(result.pid);
|
|
155
|
+
assert.ok(result.wsEndpoint);
|
|
156
|
+
assert.ok(result.debugPort);
|
|
157
|
+
assert.ok(result.viewport);
|
|
158
|
+
sessionId = result.sessionId;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('--session-id-only should return just the ID', () => {
|
|
162
|
+
const result = run('start --session-id-only');
|
|
163
|
+
assert.match(result, /^[0-9a-f]{6}$/);
|
|
164
|
+
sessionId = result;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('--viewport should set custom dimensions', () => {
|
|
168
|
+
const result = run('start --viewport 800x600');
|
|
169
|
+
assert.equal(result.viewport.width, 800);
|
|
170
|
+
assert.equal(result.viewport.height, 600);
|
|
171
|
+
sessionId = result.sessionId;
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('navigate command', () => {
|
|
176
|
+
let sessionId;
|
|
177
|
+
|
|
178
|
+
before(() => {
|
|
179
|
+
sessionId = run('start --session-id-only');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
after(() => {
|
|
183
|
+
try { run(`stop ${sessionId}`); } catch (_) {}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should navigate to a URL and return title', () => {
|
|
187
|
+
const result = run(`navigate ${sessionId} https://example.com`);
|
|
188
|
+
assert.equal(result.ok, true);
|
|
189
|
+
assert.equal(result.title, 'Example Domain');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should support --wait-for text', () => {
|
|
193
|
+
const result = run(`navigate ${sessionId} https://example.com --wait-for "Example Domain"`);
|
|
194
|
+
assert.equal(result.ok, true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should error on invalid session', () => {
|
|
198
|
+
try {
|
|
199
|
+
run('navigate nonexist999 https://example.com');
|
|
200
|
+
assert.fail('Should have thrown');
|
|
201
|
+
} catch (err) {
|
|
202
|
+
assert.ok(err.status === 1);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('screenshot command', () => {
|
|
208
|
+
let sessionId;
|
|
209
|
+
let screenshotPath;
|
|
210
|
+
|
|
211
|
+
before(() => {
|
|
212
|
+
sessionId = run('start --session-id-only');
|
|
213
|
+
run(`navigate ${sessionId} https://example.com`);
|
|
214
|
+
screenshotPath = path.join(os.tmpdir(), `fe-test-screenshot-${Date.now()}.png`);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
after(() => {
|
|
218
|
+
try { fs.unlinkSync(screenshotPath); } catch (_) {}
|
|
219
|
+
try { run(`stop ${sessionId}`); } catch (_) {}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should capture a screenshot to file', () => {
|
|
223
|
+
const result = run(`screenshot ${sessionId} ${screenshotPath}`);
|
|
224
|
+
assert.equal(result.ok, true);
|
|
225
|
+
assert.ok(fs.existsSync(result.path));
|
|
226
|
+
const stats = fs.statSync(result.path);
|
|
227
|
+
assert.ok(stats.size > 1000, 'Screenshot should be a real image (> 1KB)');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should support --full-page', () => {
|
|
231
|
+
const fpPath = screenshotPath + '.fullpage.png';
|
|
232
|
+
try {
|
|
233
|
+
const result = run(`screenshot ${sessionId} ${fpPath} --full-page`);
|
|
234
|
+
assert.equal(result.ok, true);
|
|
235
|
+
assert.ok(fs.existsSync(fpPath));
|
|
236
|
+
} finally {
|
|
237
|
+
try { fs.unlinkSync(fpPath); } catch (_) {}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('eval command', () => {
|
|
243
|
+
let sessionId;
|
|
244
|
+
|
|
245
|
+
before(() => {
|
|
246
|
+
sessionId = run('start --session-id-only');
|
|
247
|
+
run(`navigate ${sessionId} https://example.com`);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
after(() => {
|
|
251
|
+
try { run(`stop ${sessionId}`); } catch (_) {}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should evaluate a simple expression', () => {
|
|
255
|
+
const result = run(`eval ${sessionId} "document.title"`);
|
|
256
|
+
assert.equal(result.ok, true);
|
|
257
|
+
assert.equal(result.result, 'Example Domain');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should evaluate and return object', () => {
|
|
261
|
+
const result = run(`eval ${sessionId} "({a:1, b:'hello'})"`);
|
|
262
|
+
assert.equal(result.ok, true);
|
|
263
|
+
assert.deepEqual(result.result, { a: 1, b: 'hello' });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should handle --stdin for complex scripts', () => {
|
|
267
|
+
const script = '(() => { return document.querySelector("h1").textContent; })()';
|
|
268
|
+
const result = JSON.parse(execSync(
|
|
269
|
+
`echo '${script}' | node ${browserCjs} eval ${sessionId} --stdin`,
|
|
270
|
+
{ encoding: 'utf8', timeout: 10000 }
|
|
271
|
+
).trim());
|
|
272
|
+
assert.equal(result.ok, true);
|
|
273
|
+
assert.equal(result.result, 'Example Domain');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('snapshot command', () => {
|
|
278
|
+
let sessionId;
|
|
279
|
+
|
|
280
|
+
before(() => {
|
|
281
|
+
sessionId = run('start --session-id-only');
|
|
282
|
+
run(`navigate ${sessionId} https://example.com`);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
after(() => {
|
|
286
|
+
try { run(`stop ${sessionId}`); } catch (_) {}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should return accessibility tree as text', () => {
|
|
290
|
+
const result = run(`snapshot ${sessionId}`);
|
|
291
|
+
assert.equal(result.ok, true);
|
|
292
|
+
assert.ok(result.snapshot.includes('Example Domain'));
|
|
293
|
+
assert.ok(result.snapshot.includes('heading'));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should save to file with --file', () => {
|
|
297
|
+
const filePath = path.join(os.tmpdir(), `fe-test-snapshot-${Date.now()}.txt`);
|
|
298
|
+
try {
|
|
299
|
+
const result = run(`snapshot ${sessionId} --file ${filePath}`);
|
|
300
|
+
assert.equal(result.ok, true);
|
|
301
|
+
assert.ok(fs.existsSync(filePath));
|
|
302
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
303
|
+
assert.ok(content.includes('Example Domain'));
|
|
304
|
+
} finally {
|
|
305
|
+
try { fs.unlinkSync(filePath); } catch (_) {}
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('stop command', () => {
|
|
311
|
+
it('should stop browser and clean up', () => {
|
|
312
|
+
const sid = run('start --session-id-only');
|
|
313
|
+
// Verify session exists
|
|
314
|
+
assert.ok(readSession(sid));
|
|
315
|
+
// Stop
|
|
316
|
+
const result = run(`stop ${sid}`);
|
|
317
|
+
assert.equal(result.ok, true);
|
|
318
|
+
assert.equal(result.cleaned, true);
|
|
319
|
+
// Verify session removed
|
|
320
|
+
assert.equal(readSession(sid), null);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should handle already-stopped session gracefully', () => {
|
|
324
|
+
const sid = run('start --session-id-only');
|
|
325
|
+
run(`stop ${sid}`);
|
|
326
|
+
// Stop again
|
|
327
|
+
const result = run(`stop ${sid}`);
|
|
328
|
+
assert.equal(result.ok, true);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('list command', () => {
|
|
333
|
+
it('should list active sessions', () => {
|
|
334
|
+
const sid = run('start --session-id-only');
|
|
335
|
+
try {
|
|
336
|
+
const result = run('list');
|
|
337
|
+
assert.equal(result.ok, true);
|
|
338
|
+
assert.ok(Array.isArray(result.sessions));
|
|
339
|
+
const found = result.sessions.find(s => s.sessionId === sid);
|
|
340
|
+
assert.ok(found, 'Should find the active session');
|
|
341
|
+
} finally {
|
|
342
|
+
try { run(`stop ${sid}`); } catch (_) {}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('cleanup command', () => {
|
|
348
|
+
it('should clean up dead sessions', () => {
|
|
349
|
+
// Create a fake session with a dead PID
|
|
350
|
+
const fakeId = 'fake' + generateSessionId().slice(0, 2);
|
|
351
|
+
writeSession(fakeId, {
|
|
352
|
+
pid: 999999, // almost certainly dead
|
|
353
|
+
wsEndpoint: 'ws://localhost:1/fake',
|
|
354
|
+
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
|
355
|
+
});
|
|
356
|
+
const result = run('cleanup --max-age 1');
|
|
357
|
+
assert.equal(result.ok, true);
|
|
358
|
+
assert.ok(
|
|
359
|
+
result.alreadyDead.includes(fakeId) || result.cleaned.includes(fakeId),
|
|
360
|
+
'Fake session should be cleaned'
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ============================================================
|
|
367
|
+
// Parallel isolation test (the whole point)
|
|
368
|
+
// ============================================================
|
|
369
|
+
|
|
370
|
+
describe('parallel isolation', () => {
|
|
371
|
+
const sessions = [];
|
|
372
|
+
|
|
373
|
+
after(() => {
|
|
374
|
+
for (const sid of sessions) {
|
|
375
|
+
try { run(`stop ${sid}`); } catch (_) {}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('two parallel sessions should be fully isolated', async () => {
|
|
380
|
+
// Use data: URIs to avoid network dependency
|
|
381
|
+
const page1 = 'data:text/html,<html><head><title>Page Alpha</title></head><body><h1>Alpha</h1></body></html>';
|
|
382
|
+
const page2 = 'data:text/html,<html><head><title>Page Beta</title></head><body><h1>Beta</h1><p>Extra content here</p></body></html>';
|
|
383
|
+
|
|
384
|
+
// Start two sessions
|
|
385
|
+
const s1 = run('start --session-id-only');
|
|
386
|
+
const s2 = run('start --session-id-only');
|
|
387
|
+
sessions.push(s1, s2);
|
|
388
|
+
|
|
389
|
+
// Navigate both in parallel (using child processes)
|
|
390
|
+
const navigate = (sid, url) => new Promise((resolve, reject) => {
|
|
391
|
+
const proc = spawn('node', [browserCjs, 'navigate', sid, url], { stdio: 'pipe' });
|
|
392
|
+
let out = '';
|
|
393
|
+
proc.stdout.on('data', d => out += d);
|
|
394
|
+
proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(`exit ${code}`)));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await Promise.all([navigate(s1, page1), navigate(s2, page2)]);
|
|
398
|
+
|
|
399
|
+
// Verify each session has its own page
|
|
400
|
+
const title1 = run(`eval ${s1} "document.title"`);
|
|
401
|
+
const title2 = run(`eval ${s2} "document.title"`);
|
|
402
|
+
|
|
403
|
+
assert.equal(title1.result, 'Page Alpha', 'Session 1 should have Page Alpha');
|
|
404
|
+
assert.equal(title2.result, 'Page Beta', 'Session 2 should have Page Beta');
|
|
405
|
+
|
|
406
|
+
// Take screenshots and verify they are different
|
|
407
|
+
const ss1 = path.join(os.tmpdir(), `fe-iso-test-1-${Date.now()}.png`);
|
|
408
|
+
const ss2 = path.join(os.tmpdir(), `fe-iso-test-2-${Date.now()}.png`);
|
|
409
|
+
try {
|
|
410
|
+
run(`screenshot ${s1} ${ss1}`);
|
|
411
|
+
run(`screenshot ${s2} ${ss2}`);
|
|
412
|
+
|
|
413
|
+
const size1 = fs.statSync(ss1).size;
|
|
414
|
+
const size2 = fs.statSync(ss2).size;
|
|
415
|
+
// Different pages should produce different screenshots
|
|
416
|
+
assert.notEqual(size1, size2, 'Screenshots should differ in size (different pages)');
|
|
417
|
+
} finally {
|
|
418
|
+
try { fs.unlinkSync(ss1); } catch (_) {}
|
|
419
|
+
try { fs.unlinkSync(ss2); } catch (_) {}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { getConfig, setConfig, initConfig, configPath } = require('../config.cjs');
|
|
9
|
+
|
|
10
|
+
let tmpDir;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-test-'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('configPath', () => {
|
|
21
|
+
it('should return correct path', () => {
|
|
22
|
+
assert.equal(configPath(tmpDir), path.join(tmpDir, '.fe', 'config.jsonc'));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('getConfig', () => {
|
|
27
|
+
it('should return error when config does not exist', () => {
|
|
28
|
+
const result = getConfig(tmpDir);
|
|
29
|
+
assert.ok(result.error);
|
|
30
|
+
assert.ok(result.error.includes('config.jsonc not found'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return config when file exists', () => {
|
|
34
|
+
const cfgDir = path.join(tmpDir, '.fe');
|
|
35
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(cfgDir, 'config.jsonc'), JSON.stringify({ maxRetries: 5 }));
|
|
37
|
+
const result = getConfig(tmpDir);
|
|
38
|
+
assert.equal(result.maxRetries, 5);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('initConfig', () => {
|
|
43
|
+
it('should create config file with provided data', () => {
|
|
44
|
+
const config = { devServerCommand: 'npm run dev' };
|
|
45
|
+
const result = initConfig(tmpDir, config);
|
|
46
|
+
assert.equal(result.ok, true);
|
|
47
|
+
|
|
48
|
+
const saved = JSON.parse(fs.readFileSync(configPath(tmpDir), 'utf8'));
|
|
49
|
+
assert.equal(saved.devServerCommand, 'npm run dev');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('setConfig', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
initConfig(tmpDir, { maxRetries: 5, devServerCommand: '' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should set a string value', () => {
|
|
59
|
+
const result = setConfig(tmpDir, 'devServerCommand', 'pnpm dev');
|
|
60
|
+
assert.equal(result.ok, true);
|
|
61
|
+
assert.equal(result.value, 'pnpm dev');
|
|
62
|
+
|
|
63
|
+
const cfg = getConfig(tmpDir);
|
|
64
|
+
assert.equal(cfg.devServerCommand, 'pnpm dev');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should auto-parse boolean true', () => {
|
|
68
|
+
const result = setConfig(tmpDir, 'someFlag', 'true');
|
|
69
|
+
assert.equal(result.value, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should auto-parse boolean false', () => {
|
|
73
|
+
const result = setConfig(tmpDir, 'someFlag', 'false');
|
|
74
|
+
assert.equal(result.value, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should auto-parse numbers', () => {
|
|
78
|
+
const result = setConfig(tmpDir, 'maxRetries', '10');
|
|
79
|
+
assert.equal(result.value, 10);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should keep empty string as string', () => {
|
|
83
|
+
const result = setConfig(tmpDir, 'devServerCommand', '');
|
|
84
|
+
assert.equal(result.value, '');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should return error if config not initialized', () => {
|
|
88
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-empty-'));
|
|
89
|
+
const result = setConfig(emptyDir, 'key', 'val');
|
|
90
|
+
assert.ok(result.error);
|
|
91
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { getFeDir, getRuntimeDir, getContextDir, ensureDir, readJSON, writeJSON, writeFile, readFile, timestamp } = require('../core.cjs');
|
|
9
|
+
|
|
10
|
+
let tmpDir;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-test-'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// --- Path helpers ---
|
|
21
|
+
|
|
22
|
+
describe('getFeDir', () => {
|
|
23
|
+
it('should return .fe under root', () => {
|
|
24
|
+
assert.equal(getFeDir('/project'), path.join('/project', '.fe'));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('getRuntimeDir', () => {
|
|
29
|
+
it('should return .fe-runtime under root', () => {
|
|
30
|
+
assert.equal(getRuntimeDir('/project'), path.join('/project', '.fe-runtime'));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('getContextDir', () => {
|
|
35
|
+
it('should return .fe-runtime/context under root', () => {
|
|
36
|
+
assert.equal(getContextDir('/project'), path.join('/project', '.fe-runtime', 'context'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- ensureDir ---
|
|
41
|
+
|
|
42
|
+
describe('ensureDir', () => {
|
|
43
|
+
it('should create nested directories', () => {
|
|
44
|
+
const dir = path.join(tmpDir, 'a', 'b', 'c');
|
|
45
|
+
ensureDir(dir);
|
|
46
|
+
assert.ok(fs.existsSync(dir));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should not throw if directory already exists', () => {
|
|
50
|
+
ensureDir(tmpDir);
|
|
51
|
+
assert.ok(fs.existsSync(tmpDir));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// --- readJSON / writeJSON ---
|
|
56
|
+
|
|
57
|
+
describe('readJSON', () => {
|
|
58
|
+
it('should return null for non-existent file', () => {
|
|
59
|
+
assert.equal(readJSON(path.join(tmpDir, 'nope.json')), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return null for invalid JSON', () => {
|
|
63
|
+
const fp = path.join(tmpDir, 'bad.json');
|
|
64
|
+
fs.writeFileSync(fp, 'not json');
|
|
65
|
+
assert.equal(readJSON(fp), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should parse valid JSON', () => {
|
|
69
|
+
const fp = path.join(tmpDir, 'good.json');
|
|
70
|
+
fs.writeFileSync(fp, '{"a":1}');
|
|
71
|
+
assert.deepEqual(readJSON(fp), { a: 1 });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('writeJSON', () => {
|
|
76
|
+
it('should write JSON and create parent dirs', () => {
|
|
77
|
+
const fp = path.join(tmpDir, 'sub', 'data.json');
|
|
78
|
+
writeJSON(fp, { key: 'value' });
|
|
79
|
+
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
80
|
+
assert.deepEqual(data, { key: 'value' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should produce formatted JSON', () => {
|
|
84
|
+
const fp = path.join(tmpDir, 'fmt.json');
|
|
85
|
+
writeJSON(fp, { a: 1 });
|
|
86
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
87
|
+
assert.ok(raw.includes('\n'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should atomically write (no .tmp left)', () => {
|
|
91
|
+
const fp = path.join(tmpDir, 'atomic.json');
|
|
92
|
+
writeJSON(fp, { x: 1 });
|
|
93
|
+
assert.ok(!fs.existsSync(fp + '.tmp'));
|
|
94
|
+
assert.ok(fs.existsSync(fp));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- writeFile / readFile ---
|
|
99
|
+
|
|
100
|
+
describe('writeFile', () => {
|
|
101
|
+
it('should write string content and create parent dirs', () => {
|
|
102
|
+
const fp = path.join(tmpDir, 'nested', 'file.txt');
|
|
103
|
+
writeFile(fp, 'hello');
|
|
104
|
+
assert.equal(fs.readFileSync(fp, 'utf8'), 'hello');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('readFile', () => {
|
|
109
|
+
it('should return null for non-existent file', () => {
|
|
110
|
+
assert.equal(readFile(path.join(tmpDir, 'nope.txt')), null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should read file content', () => {
|
|
114
|
+
const fp = path.join(tmpDir, 'file.txt');
|
|
115
|
+
fs.writeFileSync(fp, 'content');
|
|
116
|
+
assert.equal(readFile(fp), 'content');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- timestamp ---
|
|
121
|
+
|
|
122
|
+
describe('timestamp', () => {
|
|
123
|
+
it('should return formatted timestamp (YYYY-MM-DD HH:MM:SS)', () => {
|
|
124
|
+
const ts = timestamp();
|
|
125
|
+
assert.match(ts, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
126
|
+
});
|
|
127
|
+
});
|