@polderlabs/bizar 3.17.0 → 3.20.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/cli/{plan-templates.mjs → artifact-templates.mjs} +26 -26
- package/cli/{plan.mjs → artifact.mjs} +134 -134
- package/cli/{plan.test.mjs → artifact.test.mjs} +108 -108
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +5 -5
- package/cli/install.mjs +1 -1
- package/cli/prompts.mjs +2 -2
- package/config/AGENTS.md +9 -51
- package/config/agents/_shared/AGENT_BASELINE.md +618 -0
- package/config/agents/baldr.md +29 -169
- package/config/agents/browser-harness.md +58 -0
- package/config/agents/forseti.md +31 -120
- package/config/agents/frigg.md +26 -102
- package/config/agents/heimdall.md +7 -172
- package/config/agents/hermod.md +34 -162
- package/config/agents/mimir.md +33 -140
- package/config/agents/odin.md +128 -232
- package/config/agents/quick.md +17 -77
- package/config/agents/semble-search.md +35 -40
- package/config/agents/thor.md +28 -113
- package/config/agents/tyr.md +35 -116
- package/config/agents/vidarr.md +32 -119
- package/config/agents/vor.md +37 -141
- package/config/opencode.json.template +2 -16
- package/config/rules/uncertainty.md +1 -1
- package/config/skills/bizar/SKILL.md +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* artifact.mjs tests — uses Node's built-in node:test (Node 20+)
|
|
3
3
|
* Tests: slug validation, new, list, delete, export, server routes
|
|
4
4
|
*
|
|
5
|
-
* Note: These tests import from
|
|
5
|
+
* Note: These tests import from artifact.mjs directly, so they test internal
|
|
6
6
|
* functions via their named exports. The server runs in the test process
|
|
7
7
|
* but is shut down after each test.
|
|
8
8
|
*/
|
|
@@ -20,25 +20,25 @@ import {
|
|
|
20
20
|
import { join, resolve, dirname } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
|
|
23
|
-
// Resolve
|
|
23
|
+
// Resolve artifact.mjs from this test file's location
|
|
24
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const PROJECT_ROOT = resolve(__dirname, '..');
|
|
26
|
-
const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', '
|
|
27
|
-
const PLANS_DIR = join(PROJECT_ROOT, '
|
|
26
|
+
const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'artifact');
|
|
27
|
+
const PLANS_DIR = join(PROJECT_ROOT, 'artifacts');
|
|
28
28
|
|
|
29
|
-
// ── Named imports from
|
|
30
|
-
const { runPlan, startServer, regenerateHtml } = await import('./
|
|
29
|
+
// ── Named imports from artifact.mjs ──────────────────────────────────────────────
|
|
30
|
+
const { runPlan, startServer, regenerateHtml } = await import('./artifact.mjs');
|
|
31
31
|
|
|
32
32
|
// Suppress MaxListenersWarning (each server adds SIGINT+SIGTERM listeners)
|
|
33
33
|
process.setMaxListeners(64);
|
|
34
34
|
|
|
35
35
|
// ── Test helpers ─────────────────────────────────────────────────────────────
|
|
36
36
|
|
|
37
|
-
/** Slug validation regex (mirrors
|
|
38
|
-
// Must match
|
|
37
|
+
/** Slug validation regex (mirrors artifact.mjs) */
|
|
38
|
+
// Must match artifact.mjs SLUG_REGEX: ^[a-z0-9][a-z0-9-]{0,63}$
|
|
39
39
|
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
40
40
|
|
|
41
|
-
/** Create a temp
|
|
41
|
+
/** Create a temp artifact directory for testing */
|
|
42
42
|
function createTempPlan(slug, overrides = {}) {
|
|
43
43
|
const planDir = join(PLANS_DIR, slug);
|
|
44
44
|
mkdirSync(planDir, { recursive: true });
|
|
@@ -56,35 +56,35 @@ function createTempPlan(slug, overrides = {}) {
|
|
|
56
56
|
|
|
57
57
|
writeFileSync(join(planDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
58
58
|
const mdxContent = overrides.mdx || `# ${meta.title}\n\nContent here.\n`;
|
|
59
|
-
writeFileSync(join(planDir, '
|
|
59
|
+
writeFileSync(join(planDir, 'artifact.mdx'), mdxContent, 'utf-8');
|
|
60
60
|
writeFileSync(
|
|
61
61
|
join(planDir, 'comments.json'),
|
|
62
62
|
overrides.comments || '[]',
|
|
63
63
|
'utf-8'
|
|
64
64
|
);
|
|
65
65
|
|
|
66
|
-
// Generate
|
|
67
|
-
const planMdx = readFileSync(join(planDir, '
|
|
66
|
+
// Generate artifact.html
|
|
67
|
+
const planMdx = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
|
|
68
68
|
const commentsJson = readFileSync(join(planDir, 'comments.json'), 'utf-8');
|
|
69
69
|
const metaJson = readFileSync(join(planDir, 'meta.json'), 'utf-8');
|
|
70
70
|
const planJson = JSON.stringify(planMdx);
|
|
71
71
|
const htmlContent = `<!doctype html>
|
|
72
72
|
<html>
|
|
73
|
-
<head><title>${meta.title} — Bizar
|
|
73
|
+
<head><title>${meta.title} — Bizar Artifact</title></head>
|
|
74
74
|
<body>
|
|
75
75
|
<header><h1>${meta.title}</h1></header>
|
|
76
76
|
<main><pre>${planMdx.replace(/</g, '<')}</pre></main>
|
|
77
77
|
<script>
|
|
78
|
-
const INITIAL_STATE = {
|
|
78
|
+
const INITIAL_STATE = { artifact: ${planJson}, comments: ${commentsJson}, meta: ${metaJson} };
|
|
79
79
|
</script>
|
|
80
80
|
</body>
|
|
81
81
|
</html>`;
|
|
82
|
-
writeFileSync(join(planDir, '
|
|
82
|
+
writeFileSync(join(planDir, 'artifact.html'), htmlContent, 'utf-8');
|
|
83
83
|
|
|
84
84
|
return planDir;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/** Clean up a temp
|
|
87
|
+
/** Clean up a temp artifact */
|
|
88
88
|
function cleanupPlan(slug) {
|
|
89
89
|
const planDir = join(PLANS_DIR, slug);
|
|
90
90
|
if (existsSync(planDir)) rmSync(planDir, { recursive: true });
|
|
@@ -129,18 +129,18 @@ describe('Slug validation', () => {
|
|
|
129
129
|
|
|
130
130
|
// ── createPlan / new flow tests ───────────────────────────────────────────────
|
|
131
131
|
// Note: runPlan(['new', slug]) starts a blocking server so can't be tested directly.
|
|
132
|
-
// The file creation is tested in '
|
|
132
|
+
// The file creation is tested in 'Artifact file creation' suite below.
|
|
133
133
|
// Slug validation is tested independently above.
|
|
134
134
|
|
|
135
135
|
// Test createPlan by checking the files it creates
|
|
136
|
-
describe('
|
|
136
|
+
describe('Artifact file creation', () => {
|
|
137
137
|
const TEST_SLUG = 'test-files-' + Date.now();
|
|
138
138
|
|
|
139
139
|
afterEach(() => {
|
|
140
140
|
cleanupPlan(TEST_SLUG);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
test('runPlan new creates
|
|
143
|
+
test('runPlan new creates artifact.mdx, meta.json, comments.json', async () => {
|
|
144
144
|
// We'll use a subprocess to run just the file creation part
|
|
145
145
|
// For unit testing, we directly verify the file operations work
|
|
146
146
|
mkdirSync(join(PLANS_DIR, TEST_SLUG), { recursive: true });
|
|
@@ -150,11 +150,11 @@ describe('Plan file creation', () => {
|
|
|
150
150
|
const meta = { title, slug: TEST_SLUG, status: 'draft', author: 'tester', created: now, lastEdited: now };
|
|
151
151
|
|
|
152
152
|
writeFileSync(join(PLANS_DIR, TEST_SLUG, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
153
|
-
writeFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
153
|
+
writeFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.mdx'), `# ${title}\n\nTest content.\n`, 'utf-8');
|
|
154
154
|
writeFileSync(join(PLANS_DIR, TEST_SLUG, 'comments.json'), '[]', 'utf-8');
|
|
155
155
|
|
|
156
156
|
// Verify files
|
|
157
|
-
assert.equal(existsSync(join(PLANS_DIR, TEST_SLUG, '
|
|
157
|
+
assert.equal(existsSync(join(PLANS_DIR, TEST_SLUG, 'artifact.mdx')), true);
|
|
158
158
|
assert.equal(existsSync(join(PLANS_DIR, TEST_SLUG, 'meta.json')), true);
|
|
159
159
|
assert.equal(existsSync(join(PLANS_DIR, TEST_SLUG, 'comments.json')), true);
|
|
160
160
|
|
|
@@ -184,7 +184,7 @@ describe('list flow', () => {
|
|
|
184
184
|
cleanupPlan(SLUG2);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
test('reads
|
|
187
|
+
test('reads artifacts directory and meta.json correctly', async () => {
|
|
188
188
|
const planDir1 = join(PLANS_DIR, SLUG1);
|
|
189
189
|
const planDir2 = join(PLANS_DIR, SLUG2);
|
|
190
190
|
|
|
@@ -206,17 +206,17 @@ describe('list flow', () => {
|
|
|
206
206
|
// ── delete flow tests ────────────────────────────────────────────────────────
|
|
207
207
|
|
|
208
208
|
describe('delete flow', () => {
|
|
209
|
-
const TEST_SLUG = 'test-delete-
|
|
209
|
+
const TEST_SLUG = 'test-delete-artifact-' + Date.now();
|
|
210
210
|
|
|
211
211
|
beforeEach(() => {
|
|
212
|
-
createTempPlan(TEST_SLUG, { title: 'Delete Test
|
|
212
|
+
createTempPlan(TEST_SLUG, { title: 'Delete Test Artifact' });
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
afterEach(() => {
|
|
216
216
|
cleanupPlan(TEST_SLUG);
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
test('removes
|
|
219
|
+
test('removes artifact directory', async () => {
|
|
220
220
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
221
221
|
assert.equal(existsSync(planDir), true);
|
|
222
222
|
|
|
@@ -234,8 +234,8 @@ describe('delete flow', () => {
|
|
|
234
234
|
// ── export flow tests ───────────────────────────────────────────────────────
|
|
235
235
|
|
|
236
236
|
describe('export flow', () => {
|
|
237
|
-
const TEST_SLUG = 'test-export-
|
|
238
|
-
const EXPECTED_CONTENT = '# Export Test
|
|
237
|
+
const TEST_SLUG = 'test-export-artifact-' + Date.now();
|
|
238
|
+
const EXPECTED_CONTENT = '# Export Test Artifact\n\nSome content here.\n';
|
|
239
239
|
|
|
240
240
|
beforeEach(() => {
|
|
241
241
|
createTempPlan(TEST_SLUG, { mdx: EXPECTED_CONTENT });
|
|
@@ -245,9 +245,9 @@ describe('export flow', () => {
|
|
|
245
245
|
cleanupPlan(TEST_SLUG);
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
test('reads
|
|
248
|
+
test('reads artifact.mdx content', async () => {
|
|
249
249
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
250
|
-
const content = readFileSync(join(planDir, '
|
|
250
|
+
const content = readFileSync(join(planDir, 'artifact.mdx'), 'utf-8');
|
|
251
251
|
assert.equal(content, EXPECTED_CONTENT);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
@@ -265,14 +265,14 @@ describe('help flow', () => {
|
|
|
265
265
|
// These start a real HTTP server on a random port and make actual requests.
|
|
266
266
|
|
|
267
267
|
describe('Local HTTP server', () => {
|
|
268
|
-
const TEST_SLUG = 'test-server-
|
|
268
|
+
const TEST_SLUG = 'test-server-artifact-' + Date.now();
|
|
269
269
|
let serverInfo;
|
|
270
270
|
let baseUrl;
|
|
271
271
|
|
|
272
272
|
beforeEach(async () => {
|
|
273
|
-
// Create a
|
|
273
|
+
// Create a artifact for the server to serve
|
|
274
274
|
createTempPlan(TEST_SLUG, {
|
|
275
|
-
mdx: '# Test Server
|
|
275
|
+
mdx: '# Test Server Artifact\n\nServer test content.\n',
|
|
276
276
|
comments: JSON.stringify([
|
|
277
277
|
{
|
|
278
278
|
id: '1',
|
|
@@ -299,15 +299,15 @@ describe('Local HTTP server', () => {
|
|
|
299
299
|
assert.equal(res.status, 200);
|
|
300
300
|
assert.equal(res.headers.get('content-type').includes('text/html'), true);
|
|
301
301
|
const html = await res.text();
|
|
302
|
-
assert.equal(html.includes('Test Server
|
|
302
|
+
assert.equal(html.includes('Test Server Artifact'), true);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
-
test('GET /api/
|
|
306
|
-
const res = await fetch(`${baseUrl}/api/
|
|
305
|
+
test('GET /api/artifact returns MDX', async () => {
|
|
306
|
+
const res = await fetch(`${baseUrl}/api/artifact`);
|
|
307
307
|
assert.equal(res.status, 200);
|
|
308
308
|
assert.equal(res.headers.get('content-type').includes('text/plain'), true);
|
|
309
309
|
const text = await res.text();
|
|
310
|
-
assert.equal(text.includes('Test Server
|
|
310
|
+
assert.equal(text.includes('Test Server Artifact'), true);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
test('GET /api/comments returns JSON', async () => {
|
|
@@ -320,9 +320,9 @@ describe('Local HTTP server', () => {
|
|
|
320
320
|
assert.equal(comments.length > 0, true);
|
|
321
321
|
});
|
|
322
322
|
|
|
323
|
-
test('PUT /api/
|
|
324
|
-
const newContent = '# Updated
|
|
325
|
-
const res = await fetch(`${baseUrl}/api/
|
|
323
|
+
test('PUT /api/artifact saves MDX', async () => {
|
|
324
|
+
const newContent = '# Updated Artifact\n\nUpdated content.\n';
|
|
325
|
+
const res = await fetch(`${baseUrl}/api/artifact`, {
|
|
326
326
|
method: 'PUT',
|
|
327
327
|
body: newContent,
|
|
328
328
|
headers: { 'Content-Type': 'text/plain' },
|
|
@@ -330,7 +330,7 @@ describe('Local HTTP server', () => {
|
|
|
330
330
|
assert.equal(res.status, 200);
|
|
331
331
|
|
|
332
332
|
// Verify file was updated
|
|
333
|
-
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
333
|
+
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.mdx'), 'utf-8');
|
|
334
334
|
assert.equal(saved, newContent);
|
|
335
335
|
});
|
|
336
336
|
|
|
@@ -386,7 +386,7 @@ describe('Local HTTP server', () => {
|
|
|
386
386
|
});
|
|
387
387
|
|
|
388
388
|
test('CORS headers are set', async () => {
|
|
389
|
-
const res = await fetch(`${baseUrl}/api/
|
|
389
|
+
const res = await fetch(`${baseUrl}/api/artifact`, { method: 'OPTIONS' });
|
|
390
390
|
assert.equal(res.headers.get('access-control-allow-origin'), '*');
|
|
391
391
|
assert.equal(
|
|
392
392
|
res.headers.get('access-control-allow-methods').includes('GET'),
|
|
@@ -417,7 +417,7 @@ describe('Local HTTP server', () => {
|
|
|
417
417
|
});
|
|
418
418
|
|
|
419
419
|
// ── Template library tests ─────────────────────────────────────────────────────
|
|
420
|
-
// Tests for
|
|
420
|
+
// Tests for artifact-templates.mjs — getTemplate, getTemplateNames, printTemplates, etc.
|
|
421
421
|
|
|
422
422
|
import {
|
|
423
423
|
getTemplate,
|
|
@@ -426,7 +426,7 @@ import {
|
|
|
426
426
|
printTemplates,
|
|
427
427
|
substitute,
|
|
428
428
|
buildVars,
|
|
429
|
-
} from './
|
|
429
|
+
} from './artifact-templates.mjs';
|
|
430
430
|
|
|
431
431
|
describe('Template library — getTemplate', () => {
|
|
432
432
|
test('getTemplate("feature-design") returns content', () => {
|
|
@@ -568,7 +568,7 @@ describe('Template library — buildVars', () => {
|
|
|
568
568
|
assert.ok(vars.author, 'author should be set');
|
|
569
569
|
assert.ok(vars.created, 'created should be set');
|
|
570
570
|
assert.ok(vars.lastEdited, 'lastEdited should be set');
|
|
571
|
-
assert.equal(vars.lastEdited, vars.created, 'created and lastEdited should match for a new
|
|
571
|
+
assert.equal(vars.lastEdited, vars.created, 'created and lastEdited should match for a new artifact');
|
|
572
572
|
});
|
|
573
573
|
|
|
574
574
|
test('auto-generates title from slug when not provided', () => {
|
|
@@ -587,7 +587,7 @@ describe('Template library — template content via runPlan', () => {
|
|
|
587
587
|
const TEST_SLUG = 'test-tpl-new-' + Date.now();
|
|
588
588
|
|
|
589
589
|
afterEach(() => {
|
|
590
|
-
// Clean up
|
|
590
|
+
// Clean up artifact directory if created
|
|
591
591
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
592
592
|
if (existsSync(planDir)) rmSync(planDir, { recursive: true, force: true });
|
|
593
593
|
});
|
|
@@ -599,14 +599,14 @@ describe('Template library — template content via runPlan', () => {
|
|
|
599
599
|
);
|
|
600
600
|
assert.equal(result, false, 'should return false for invalid template');
|
|
601
601
|
|
|
602
|
-
// Verify no
|
|
602
|
+
// Verify no artifact directory was left behind
|
|
603
603
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
604
604
|
assert.equal(existsSync(planDir), false, 'should clean up on error');
|
|
605
605
|
});
|
|
606
606
|
});
|
|
607
607
|
|
|
608
608
|
// ── Comment regression: createPlan → POST /api/comments ──────────────────────
|
|
609
|
-
// This regression test ensures that a freshly created
|
|
609
|
+
// This regression test ensures that a freshly created artifact (with comments.json
|
|
610
610
|
// as an empty array []) can receive the first comment via POST without crashing.
|
|
611
611
|
// Previously, createPlan wrote {schemaVersion: 2, threads: []} which caused
|
|
612
612
|
// JSON.parse + .push() to fail on the first comment.
|
|
@@ -621,8 +621,8 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
621
621
|
cleanupPlan(TEST_SLUG);
|
|
622
622
|
});
|
|
623
623
|
|
|
624
|
-
test('first comment on freshly created
|
|
625
|
-
// Create a
|
|
624
|
+
test('first comment on freshly created artifact works (array shape)', async () => {
|
|
625
|
+
// Create a artifact directory — replicating what createPlan() does after the fix
|
|
626
626
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
627
627
|
mkdirSync(planDir, { recursive: true });
|
|
628
628
|
|
|
@@ -636,7 +636,7 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
636
636
|
lastEdited: now,
|
|
637
637
|
};
|
|
638
638
|
writeFileSync(join(planDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8');
|
|
639
|
-
writeFileSync(join(planDir, '
|
|
639
|
+
writeFileSync(join(planDir, 'artifact.mdx'), '# Comment Regression Test\n\nTest.\n', 'utf-8');
|
|
640
640
|
// Write as [] — same shape as the fixed createPlan() now produces
|
|
641
641
|
writeFileSync(join(planDir, 'comments.json'), '[]', 'utf-8');
|
|
642
642
|
|
|
@@ -650,7 +650,7 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
650
650
|
headers: { 'Content-Type': 'application/json' },
|
|
651
651
|
body: JSON.stringify({
|
|
652
652
|
sectionId: 'regression-test',
|
|
653
|
-
text: 'First comment on a fresh
|
|
653
|
+
text: 'First comment on a fresh artifact',
|
|
654
654
|
author: 'test-gate',
|
|
655
655
|
}),
|
|
656
656
|
});
|
|
@@ -661,7 +661,7 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
661
661
|
assert.equal(Array.isArray(updated), true, 'comments should be an array');
|
|
662
662
|
assert.equal(updated.length, 1, 'should have exactly 1 comment');
|
|
663
663
|
assert.equal(updated[0].sectionId, 'regression-test');
|
|
664
|
-
assert.equal(updated[0].text, 'First comment on a fresh
|
|
664
|
+
assert.equal(updated[0].text, 'First comment on a fresh artifact');
|
|
665
665
|
assert.equal(updated[0].author, 'test-gate');
|
|
666
666
|
|
|
667
667
|
// Verify the file is still valid JSON
|
|
@@ -700,8 +700,8 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
700
700
|
|
|
701
701
|
// ── htmx-friendly RESTful routes ───────────────────────────────────────────────
|
|
702
702
|
// New slug-scoped routes designed for htmx (and the new HTML template).
|
|
703
|
-
// GET /api/<slug>/
|
|
704
|
-
// PUT /api/<slug>/
|
|
703
|
+
// GET /api/<slug>/artifact → MDX text/plain
|
|
704
|
+
// PUT /api/<slug>/artifact → save MDX (form data or raw body)
|
|
705
705
|
// GET /api/<slug>/comments → JSON (default) or HTML (?format=html)
|
|
706
706
|
// POST /api/<slug>/comments → add comment, returns <li> HTML
|
|
707
707
|
// PUT /api/<slug>/comments → replace comments array (JSON)
|
|
@@ -709,7 +709,7 @@ describe('Comment regression: createPlan → POST /api/comments', () => {
|
|
|
709
709
|
// GET /htmx.min.js → self-hosted htmx library
|
|
710
710
|
// The cross-slug protection: any path with a different slug in the URL
|
|
711
711
|
// returns 403 to prevent the server from being tricked into serving
|
|
712
|
-
// data for a different
|
|
712
|
+
// data for a different artifact.
|
|
713
713
|
|
|
714
714
|
describe('htmx-friendly RESTful routes', () => {
|
|
715
715
|
const TEST_SLUG = 'test-htmx-routes-' + Date.now();
|
|
@@ -746,45 +746,45 @@ describe('htmx-friendly RESTful routes', () => {
|
|
|
746
746
|
cleanupPlan(TEST_SLUG);
|
|
747
747
|
});
|
|
748
748
|
|
|
749
|
-
// ──
|
|
750
|
-
test('GET /api/<slug>/
|
|
751
|
-
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/
|
|
749
|
+
// ── Artifact routes ────────────────────────────────────────────────────
|
|
750
|
+
test('GET /api/<slug>/artifact returns MDX as text/plain', async () => {
|
|
751
|
+
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/artifact`);
|
|
752
752
|
assert.equal(res.status, 200);
|
|
753
753
|
assert.equal(res.headers.get('content-type').includes('text/plain'), true);
|
|
754
754
|
const text = await res.text();
|
|
755
755
|
assert.equal(text.includes('Htmx Routes Test'), true);
|
|
756
756
|
});
|
|
757
757
|
|
|
758
|
-
test('PUT /api/<slug>/
|
|
759
|
-
const newContent = '# Updated Htmx
|
|
760
|
-
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/
|
|
758
|
+
test('PUT /api/<slug>/artifact with form data saves MDX', async () => {
|
|
759
|
+
const newContent = '# Updated Htmx Artifact\n\nNew content.\n';
|
|
760
|
+
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/artifact`, {
|
|
761
761
|
method: 'PUT',
|
|
762
762
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
763
763
|
body: 'content=' + encodeURIComponent(newContent),
|
|
764
764
|
});
|
|
765
765
|
assert.equal(res.status, 200);
|
|
766
|
-
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
766
|
+
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.mdx'), 'utf-8');
|
|
767
767
|
assert.equal(saved, newContent);
|
|
768
768
|
});
|
|
769
769
|
|
|
770
|
-
test('PUT /api/<slug>/
|
|
771
|
-
const newContent = '# Raw Body
|
|
772
|
-
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/
|
|
770
|
+
test('PUT /api/<slug>/artifact with raw text body saves MDX', async () => {
|
|
771
|
+
const newContent = '# Raw Body Artifact\n\nRaw body content.\n';
|
|
772
|
+
const res = await fetch(`${baseUrl}/api/${TEST_SLUG}/artifact`, {
|
|
773
773
|
method: 'PUT',
|
|
774
774
|
headers: { 'Content-Type': 'text/plain' },
|
|
775
775
|
body: newContent,
|
|
776
776
|
});
|
|
777
777
|
assert.equal(res.status, 200);
|
|
778
|
-
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
778
|
+
const saved = readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.mdx'), 'utf-8');
|
|
779
779
|
assert.equal(saved, newContent);
|
|
780
780
|
});
|
|
781
781
|
|
|
782
|
-
test('PUT /api/<slug>/
|
|
782
|
+
test('PUT /api/<slug>/artifact updates lastEdited in meta.json', async () => {
|
|
783
783
|
const metaPath = join(PLANS_DIR, TEST_SLUG, 'meta.json');
|
|
784
784
|
const before = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
785
785
|
// Wait a tick so the timestamp actually advances
|
|
786
786
|
await new Promise((r) => setTimeout(r, 10));
|
|
787
|
-
await fetch(`${baseUrl}/api/${TEST_SLUG}/
|
|
787
|
+
await fetch(`${baseUrl}/api/${TEST_SLUG}/artifact`, {
|
|
788
788
|
method: 'PUT',
|
|
789
789
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
790
790
|
body: 'content=' + encodeURIComponent('# updated\n'),
|
|
@@ -825,7 +825,7 @@ describe('htmx-friendly RESTful routes', () => {
|
|
|
825
825
|
});
|
|
826
826
|
|
|
827
827
|
test('GET /api/<slug>/comments?format=html with no comments returns the empty marker', async () => {
|
|
828
|
-
// Make a fresh
|
|
828
|
+
// Make a fresh artifact with no comments
|
|
829
829
|
cleanupPlan(TEST_SLUG);
|
|
830
830
|
createTempPlan(TEST_SLUG + '-empty', { mdx: '# Empty\n', comments: '[]' });
|
|
831
831
|
const info = await startServer(TEST_SLUG + '-empty', join(PLANS_DIR, TEST_SLUG + '-empty'), 0);
|
|
@@ -968,7 +968,7 @@ describe('htmx-friendly RESTful routes', () => {
|
|
|
968
968
|
});
|
|
969
969
|
|
|
970
970
|
test('cross-slug PUT also returns 403', async () => {
|
|
971
|
-
const res = await fetch(`${baseUrl}/api/wrong-slug/
|
|
971
|
+
const res = await fetch(`${baseUrl}/api/wrong-slug/artifact`, {
|
|
972
972
|
method: 'PUT',
|
|
973
973
|
body: 'evil=true',
|
|
974
974
|
});
|
|
@@ -983,7 +983,7 @@ describe('htmx-friendly RESTful routes', () => {
|
|
|
983
983
|
});
|
|
984
984
|
|
|
985
985
|
// ── HTML template smoke tests ─────────────────────────────────────────────────
|
|
986
|
-
// These tests regenerate
|
|
986
|
+
// These tests regenerate artifact.html and verify the resulting HTML
|
|
987
987
|
// uses htmx attributes (and does NOT use fetch() in JS for the
|
|
988
988
|
// save/comment flows). The script tag for htmx should reference
|
|
989
989
|
// the local /htmx.min.js path.
|
|
@@ -995,11 +995,11 @@ describe('HTML template uses htmx', () => {
|
|
|
995
995
|
cleanupPlan(TEST_SLUG);
|
|
996
996
|
});
|
|
997
997
|
|
|
998
|
-
// Test the v1 template (
|
|
999
|
-
// htmx-based; the v2 canvas template (
|
|
998
|
+
// Test the v1 template (artifact.html.template) directly. The v1 template is
|
|
999
|
+
// htmx-based; the v2 canvas template (artifact.canvas.template) is a separate
|
|
1000
1000
|
// file with its own tests below.
|
|
1001
|
-
test('v1
|
|
1002
|
-
const tplPath = join(TEMPLATES_DIR, '
|
|
1001
|
+
test('v1 artifact.html.template uses htmx (read directly)', () => {
|
|
1002
|
+
const tplPath = join(TEMPLATES_DIR, 'artifact.html.template');
|
|
1003
1003
|
const tpl = readFileSync(tplPath, 'utf-8');
|
|
1004
1004
|
|
|
1005
1005
|
// Should load htmx from the local path
|
|
@@ -1033,7 +1033,7 @@ describe('HTML template uses htmx', () => {
|
|
|
1033
1033
|
assert.ok(!/fetch\s*\(/.test(noCommentFetch), 'v1 non-comment code should not call fetch() directly');
|
|
1034
1034
|
});
|
|
1035
1035
|
|
|
1036
|
-
test('regenerated
|
|
1036
|
+
test('regenerated artifact.html uses the v2 canvas template by default', async () => {
|
|
1037
1037
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1038
1038
|
mkdirSync(planDir, { recursive: true });
|
|
1039
1039
|
writeFileSync(
|
|
@@ -1047,17 +1047,17 @@ describe('HTML template uses htmx', () => {
|
|
|
1047
1047
|
lastEdited: '2026-06-01T00:00:00.000Z',
|
|
1048
1048
|
})
|
|
1049
1049
|
);
|
|
1050
|
-
writeFileSync(join(planDir, '
|
|
1050
|
+
writeFileSync(join(planDir, 'artifact.mdx'), '# V2 Default Test\n\n## Section\n\nHello.\n');
|
|
1051
1051
|
writeFileSync(join(planDir, 'comments.json'), '[]');
|
|
1052
1052
|
|
|
1053
1053
|
await regenerateHtml(TEST_SLUG);
|
|
1054
1054
|
|
|
1055
|
-
const html = readFileSync(join(planDir, '
|
|
1055
|
+
const html = readFileSync(join(planDir, 'artifact.html'), 'utf-8');
|
|
1056
1056
|
|
|
1057
|
-
// The default regenerated
|
|
1058
|
-
assert.ok(html.includes('id="canvas"'), 'default
|
|
1057
|
+
// The default regenerated artifact.html should be the v2 canvas view
|
|
1058
|
+
assert.ok(html.includes('id="canvas"'), 'default artifact.html should be the v2 canvas view');
|
|
1059
1059
|
assert.ok(html.includes('id="connections-layer"'),
|
|
1060
|
-
'default
|
|
1060
|
+
'default artifact.html should have the SVG connections layer');
|
|
1061
1061
|
|
|
1062
1062
|
// Should have all placeholders substituted
|
|
1063
1063
|
assert.ok(!html.includes('{{slug}}'), 'slug placeholder should be substituted');
|
|
@@ -1073,7 +1073,7 @@ describe('HTML template uses htmx', () => {
|
|
|
1073
1073
|
});
|
|
1074
1074
|
|
|
1075
1075
|
test('template is smaller than the pre-htmx version (regression check)', () => {
|
|
1076
|
-
const tplPath = join(TEMPLATES_DIR, '
|
|
1076
|
+
const tplPath = join(TEMPLATES_DIR, 'artifact.html.template');
|
|
1077
1077
|
const lines = readFileSync(tplPath, 'utf-8').split('\n').length;
|
|
1078
1078
|
assert.ok(lines < 1039, `template should be smaller than the original 1039 lines, got ${lines}`);
|
|
1079
1079
|
});
|
|
@@ -1105,7 +1105,7 @@ import {
|
|
|
1105
1105
|
makeConnectionId,
|
|
1106
1106
|
makeCommentId,
|
|
1107
1107
|
makeReplyId,
|
|
1108
|
-
} from './
|
|
1108
|
+
} from './artifact.mjs';
|
|
1109
1109
|
|
|
1110
1110
|
describe('Canvas helpers (pure functions)', () => {
|
|
1111
1111
|
test('emptyCanvas returns a v2 schema with empty arrays', () => {
|
|
@@ -1118,9 +1118,9 @@ describe('Canvas helpers (pure functions)', () => {
|
|
|
1118
1118
|
assert.deepEqual(c.viewport, { x: 0, y: 0, zoom: 1 });
|
|
1119
1119
|
});
|
|
1120
1120
|
|
|
1121
|
-
test('emptyCanvas with no title uses "Untitled
|
|
1121
|
+
test('emptyCanvas with no title uses "Untitled artifact"', () => {
|
|
1122
1122
|
const c = emptyCanvas();
|
|
1123
|
-
assert.equal(c.title, 'Untitled
|
|
1123
|
+
assert.equal(c.title, 'Untitled artifact');
|
|
1124
1124
|
});
|
|
1125
1125
|
|
|
1126
1126
|
test('id generators produce namespaced ids', () => {
|
|
@@ -1209,24 +1209,24 @@ describe('loadOrMigrateCanvas', () => {
|
|
|
1209
1209
|
cleanupPlan(TEST_SLUG);
|
|
1210
1210
|
});
|
|
1211
1211
|
|
|
1212
|
-
test('returns existing
|
|
1212
|
+
test('returns existing artifact.json if present', () => {
|
|
1213
1213
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1214
1214
|
mkdirSync(planDir, { recursive: true });
|
|
1215
1215
|
const canvas = {
|
|
1216
1216
|
schemaVersion: 2, title: 'Existing', elements: [], connections: [], comments: [],
|
|
1217
1217
|
viewport: { x: 0, y: 0, zoom: 1 },
|
|
1218
1218
|
};
|
|
1219
|
-
writeFileSync(join(planDir, '
|
|
1219
|
+
writeFileSync(join(planDir, 'artifact.json'), JSON.stringify(canvas));
|
|
1220
1220
|
|
|
1221
1221
|
const result = loadOrMigrateCanvas(planDir, 'Existing');
|
|
1222
1222
|
assert.equal(result.title, 'Existing');
|
|
1223
1223
|
});
|
|
1224
1224
|
|
|
1225
|
-
test('migrates
|
|
1225
|
+
test('migrates artifact.mdx to canvas on first read', () => {
|
|
1226
1226
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1227
1227
|
mkdirSync(planDir, { recursive: true });
|
|
1228
1228
|
const mdx = '# Hello\n\n## World\n\nFoo bar\n';
|
|
1229
|
-
writeFileSync(join(planDir, '
|
|
1229
|
+
writeFileSync(join(planDir, 'artifact.mdx'), mdx);
|
|
1230
1230
|
|
|
1231
1231
|
const result = loadOrMigrateCanvas(planDir, 'Migrated');
|
|
1232
1232
|
assert.equal(result.schemaVersion, 2);
|
|
@@ -1234,11 +1234,11 @@ describe('loadOrMigrateCanvas', () => {
|
|
|
1234
1234
|
assert.equal(result.elements[0].type, 'text');
|
|
1235
1235
|
assert.ok(result.elements[0].content.includes('Foo bar'));
|
|
1236
1236
|
|
|
1237
|
-
// After migration,
|
|
1238
|
-
assert.equal(existsSync(join(planDir, '
|
|
1237
|
+
// After migration, artifact.json should be on disk.
|
|
1238
|
+
assert.equal(existsSync(join(planDir, 'artifact.json')), true);
|
|
1239
1239
|
});
|
|
1240
1240
|
|
|
1241
|
-
test('returns empty canvas if no
|
|
1241
|
+
test('returns empty canvas if no artifact.json and no artifact.mdx', () => {
|
|
1242
1242
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1243
1243
|
mkdirSync(planDir, { recursive: true });
|
|
1244
1244
|
|
|
@@ -1246,15 +1246,15 @@ describe('loadOrMigrateCanvas', () => {
|
|
|
1246
1246
|
assert.equal(result.title, 'Brand New');
|
|
1247
1247
|
assert.equal(result.elements.length, 0);
|
|
1248
1248
|
|
|
1249
|
-
// Should have created
|
|
1250
|
-
assert.equal(existsSync(join(planDir, '
|
|
1249
|
+
// Should have created artifact.json.
|
|
1250
|
+
assert.equal(existsSync(join(planDir, 'artifact.json')), true);
|
|
1251
1251
|
});
|
|
1252
1252
|
|
|
1253
1253
|
test('backfills missing arrays on a partial canvas', () => {
|
|
1254
1254
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1255
1255
|
mkdirSync(planDir, { recursive: true });
|
|
1256
1256
|
// Write a partial canvas (missing comments)
|
|
1257
|
-
writeFileSync(join(planDir, '
|
|
1257
|
+
writeFileSync(join(planDir, 'artifact.json'), JSON.stringify({
|
|
1258
1258
|
schemaVersion: 2, title: 'Partial',
|
|
1259
1259
|
elements: [], connections: [],
|
|
1260
1260
|
}));
|
|
@@ -1305,8 +1305,8 @@ describe('Canvas HTTP endpoints', () => {
|
|
|
1305
1305
|
|
|
1306
1306
|
test('GET /api/<slug>/canvas auto-migrates from mdx on first call', async () => {
|
|
1307
1307
|
const planDir = join(PLANS_DIR, TEST_SLUG);
|
|
1308
|
-
// First, clear the auto-generated
|
|
1309
|
-
const jsonPath = join(planDir, '
|
|
1308
|
+
// First, clear the auto-generated artifact.json so we test the migration path
|
|
1309
|
+
const jsonPath = join(planDir, 'artifact.json');
|
|
1310
1310
|
if (existsSync(jsonPath)) rmSync(jsonPath);
|
|
1311
1311
|
assert.equal(existsSync(jsonPath), false);
|
|
1312
1312
|
|
|
@@ -1316,7 +1316,7 @@ describe('Canvas HTTP endpoints', () => {
|
|
|
1316
1316
|
// The migration creates a text element from the mdx
|
|
1317
1317
|
assert.equal(data.elements.length, 1);
|
|
1318
1318
|
assert.equal(data.elements[0].type, 'text');
|
|
1319
|
-
// After this call,
|
|
1319
|
+
// After this call, artifact.json should exist
|
|
1320
1320
|
assert.equal(existsSync(jsonPath), true);
|
|
1321
1321
|
});
|
|
1322
1322
|
|
|
@@ -1340,7 +1340,7 @@ describe('Canvas HTTP endpoints', () => {
|
|
|
1340
1340
|
assert.equal(res.status, 200);
|
|
1341
1341
|
|
|
1342
1342
|
// Verify the file was written
|
|
1343
|
-
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
1343
|
+
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.json'), 'utf-8'));
|
|
1344
1344
|
assert.equal(saved.title, 'Updated');
|
|
1345
1345
|
assert.equal(saved.elements.length, 2);
|
|
1346
1346
|
assert.equal(saved.viewport.zoom, 1.5);
|
|
@@ -1355,7 +1355,7 @@ describe('Canvas HTTP endpoints', () => {
|
|
|
1355
1355
|
body: JSON.stringify(partial),
|
|
1356
1356
|
});
|
|
1357
1357
|
assert.equal(res.status, 200);
|
|
1358
|
-
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
1358
|
+
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.json'), 'utf-8'));
|
|
1359
1359
|
assert.deepEqual(saved.elements, []);
|
|
1360
1360
|
assert.deepEqual(saved.connections, []);
|
|
1361
1361
|
assert.deepEqual(saved.comments, []);
|
|
@@ -1738,7 +1738,7 @@ import {
|
|
|
1738
1738
|
renderCommentPinHTML,
|
|
1739
1739
|
renderCommentThreadHTML,
|
|
1740
1740
|
renderReplyHTML,
|
|
1741
|
-
} from './
|
|
1741
|
+
} from './artifact.mjs';
|
|
1742
1742
|
|
|
1743
1743
|
describe('htmx content negotiation on canvas endpoints', () => {
|
|
1744
1744
|
const TEST_SLUG = 'test-htmx-negotiate-' + Date.now();
|
|
@@ -2082,7 +2082,7 @@ describe('htmx content negotiation on canvas endpoints', () => {
|
|
|
2082
2082
|
assert.equal(res.status, 200);
|
|
2083
2083
|
|
|
2084
2084
|
// Verify the saved state has proper arrays/objects, not JSON strings
|
|
2085
|
-
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, '
|
|
2085
|
+
const saved = JSON.parse(readFileSync(join(PLANS_DIR, TEST_SLUG, 'artifact.json'), 'utf-8'));
|
|
2086
2086
|
assert.equal(saved.title, 'Form Encoded');
|
|
2087
2087
|
assert.ok(Array.isArray(saved.elements));
|
|
2088
2088
|
assert.equal(saved.elements.length, 1);
|
|
@@ -2211,17 +2211,17 @@ describe('htmx content negotiation on canvas endpoints', () => {
|
|
|
2211
2211
|
});
|
|
2212
2212
|
|
|
2213
2213
|
// ── Canvas template structure tests ────────────────────────────────────────
|
|
2214
|
-
// Verify the new
|
|
2214
|
+
// Verify the new artifact.canvas.template has the right shape:
|
|
2215
2215
|
// - pan/zoom controls
|
|
2216
2216
|
// - SVG layer for connections
|
|
2217
2217
|
// - comment pins
|
|
2218
2218
|
// - toolbar with element-type buttons
|
|
2219
2219
|
|
|
2220
|
-
describe('
|
|
2221
|
-
const tplPath = join(TEMPLATES_DIR, '
|
|
2220
|
+
describe('artifact.canvas.template structure', () => {
|
|
2221
|
+
const tplPath = join(TEMPLATES_DIR, 'artifact.canvas.template');
|
|
2222
2222
|
|
|
2223
2223
|
test('template file exists', () => {
|
|
2224
|
-
assert.ok(existsSync(tplPath), '
|
|
2224
|
+
assert.ok(existsSync(tplPath), 'artifact.canvas.template should exist');
|
|
2225
2225
|
});
|
|
2226
2226
|
|
|
2227
2227
|
test('template declares {{canvasJson}} placeholder', () => {
|
|
@@ -2328,4 +2328,4 @@ describe('plan.canvas.template structure', () => {
|
|
|
2328
2328
|
|
|
2329
2329
|
// ── End of template tests ──────────────────────────────────────────────────────
|
|
2330
2330
|
|
|
2331
|
-
console.log('
|
|
2331
|
+
console.log(' artifact.mjs tests loaded — run with: node --test cli/artifact.test.mjs');
|