mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004) /context-save now starts every Remaining Work item with `Open.` and ends it with how this session knows it: `(path run)` with the observed outcome, `(path read)` or `(path assumed)` for concrete paths, and `(target state checked)`, `(code read)` or `(path assumed)` for writing steps. /context-restore keeps saved order and original text but splits the list into Next steps and Verify first (assumed, code-read, failed runs and unmarked concrete or writing items), prints a banner for pre-marker checkpoints, and option A walks saved order so an unverified first item is verified before any later step runs. Adds a prompt-byte contract test for the marker wire format and a periodic behavior E2E (context-restore-provenance-order) that seeds an assumed item ahead of a run item plus a code-read write. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design): never overwrite a paid design image (#1529) Every image the API returns now goes through one persistence path (design/src/persist.ts): mkdir, exclusive `wx` claim that bumps x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the bytes arrived removes the claimed file and keeps a 0600 recovery copy in the temp dir; empty image data is a failure, never a 0-byte PNG. The API call is the only thing inside each retry loop, so a local save failure never buys a second image (variants, iterate fallback, generate --retry). generate, iterate, evolve and variants (both branches) print the same run fields (requested, saved, selected, failures, recovered) on every exit, with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each generate --retry attempt is its own saved image listed in `attempts`. compare accepts --images-file and JSON-array --images. The gallery lists bumped names, orders history by mtime then board-images.json, and marks approvals by approved_path. New bin/gstack-design-claim publishes staged images without clobbering; bin/gstack-design-approved resolves an approval record (legacy letter-only records still resolve; a missing approved_path is an error, never a substitute). Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design skills): read the paths $D printed, never fixed variant names (#1529) Every design flow now captures each generating $D command's JSON and exit code without set -e, does round accounting before the first board, check or inline view (stopping when nothing was saved), and carries the printed paths forward. Boards are built from board-images.json via compare --images-file after archiving any earlier feedback.json, and approvals map the confirmed letter through that file and record approved_path. design-shotgun subagents generate into a per-run mktemp staging directory and publish with bin/gstack-design-claim instead of a shared /tmp/variant-X.png plus cp. design-html and taste memory resolve approvals with gstack-design-approved; design-review verifies against the printed target path. test/design-printed-paths.test.ts bans fixed variant names, variant globs, variant-{letter} reads, cp/mv of $D output and literal check paths in the generated skills and resolvers (one content-anchored allowlist entry: the design-html discovery probe), and pins the accounting/board marker order. test/design-approval-flow.test.ts runs the generated board and approval blocks through a stubbed two-round flow. * chore: release v1.91.17.0 (mvanhorn fix wave: checkpoint provenance, never-overwrite design images) * fix(design): keep iterate sessions in the OS temp dir and normalize tripwire paths on Windows * fix(setup-deploy): report failing status commands, specify merge-method, health and workflow checks --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
174 lines
7.6 KiB
TypeScript
174 lines
7.6 KiB
TypeScript
/**
|
|
* Tests for the $D gallery command — design history timeline generation.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { generateGalleryHtml, listVariantImages } from '../src/gallery';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
let tmpDir: string;
|
|
|
|
function createTestPng(filePath: string): void {
|
|
const png = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAI/AL+hc2rNAAAAABJRU5ErkJggg==',
|
|
'base64'
|
|
);
|
|
fs.writeFileSync(filePath, png);
|
|
}
|
|
|
|
beforeAll(() => {
|
|
tmpDir = '/tmp/gallery-test-' + Date.now();
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('Gallery generation', () => {
|
|
test('empty directory returns "No history" page', () => {
|
|
const emptyDir = path.join(tmpDir, 'empty');
|
|
fs.mkdirSync(emptyDir, { recursive: true });
|
|
|
|
const html = generateGalleryHtml(emptyDir);
|
|
expect(html).toContain('No design history yet');
|
|
expect(html).toContain('/design-shotgun');
|
|
});
|
|
|
|
test('nonexistent directory returns "No history" page', () => {
|
|
const html = generateGalleryHtml('/nonexistent/path');
|
|
expect(html).toContain('No design history yet');
|
|
});
|
|
|
|
test('single session with approved variant', () => {
|
|
const sessionDir = path.join(tmpDir, 'designs', 'homepage-20260327');
|
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
|
|
createTestPng(path.join(sessionDir, 'variant-A.png'));
|
|
createTestPng(path.join(sessionDir, 'variant-B.png'));
|
|
createTestPng(path.join(sessionDir, 'variant-C.png'));
|
|
|
|
fs.writeFileSync(path.join(sessionDir, 'approved.json'), JSON.stringify({
|
|
approved_variant: 'B',
|
|
feedback: 'Great spacing and colors',
|
|
date: '2026-03-27T12:00:00Z',
|
|
screen: 'homepage',
|
|
}));
|
|
|
|
const html = generateGalleryHtml(path.join(tmpDir, 'designs'));
|
|
expect(html).toContain('Design History');
|
|
expect(html).toContain('1 exploration');
|
|
expect(html).toContain('homepage');
|
|
expect(html).toContain('2026-03-27');
|
|
expect(html).toContain('approved');
|
|
expect(html).toContain('Great spacing and colors');
|
|
// Should have 3 variant images (base64)
|
|
expect(html).toContain('data:image/png;base64,');
|
|
});
|
|
|
|
test('multiple sessions sorted by date (newest first)', () => {
|
|
const dir = path.join(tmpDir, 'multi');
|
|
const session1 = path.join(dir, 'settings-20260301');
|
|
const session2 = path.join(dir, 'dashboard-20260315');
|
|
fs.mkdirSync(session1, { recursive: true });
|
|
fs.mkdirSync(session2, { recursive: true });
|
|
|
|
createTestPng(path.join(session1, 'variant-A.png'));
|
|
createTestPng(path.join(session2, 'variant-A.png'));
|
|
|
|
fs.writeFileSync(path.join(session1, 'approved.json'), JSON.stringify({
|
|
approved_variant: 'A', date: '2026-03-01T12:00:00Z',
|
|
}));
|
|
fs.writeFileSync(path.join(session2, 'approved.json'), JSON.stringify({
|
|
approved_variant: 'A', date: '2026-03-15T12:00:00Z',
|
|
}));
|
|
|
|
const html = generateGalleryHtml(dir);
|
|
expect(html).toContain('2 explorations');
|
|
// Dashboard (Mar 15) should appear before settings (Mar 1)
|
|
const dashIdx = html.indexOf('dashboard');
|
|
const settingsIdx = html.indexOf('settings');
|
|
expect(dashIdx).toBeLessThan(settingsIdx);
|
|
});
|
|
|
|
test('corrupted approved.json is handled gracefully', () => {
|
|
const dir = path.join(tmpDir, 'corrupt');
|
|
const session = path.join(dir, 'broken-20260327');
|
|
fs.mkdirSync(session, { recursive: true });
|
|
|
|
createTestPng(path.join(session, 'variant-A.png'));
|
|
fs.writeFileSync(path.join(session, 'approved.json'), 'NOT VALID JSON {{{');
|
|
|
|
const html = generateGalleryHtml(dir);
|
|
// Should still render the session, just without any variant marked as approved
|
|
expect(html).toContain('Design History');
|
|
expect(html).toContain('broken');
|
|
// The class "approved" should not appear on any variant div (only in CSS definition)
|
|
expect(html).not.toContain('class="gallery-variant approved"');
|
|
});
|
|
|
|
test('session without approved.json still renders', () => {
|
|
const dir = path.join(tmpDir, 'no-approved');
|
|
const session = path.join(dir, 'draft-20260327');
|
|
fs.mkdirSync(session, { recursive: true });
|
|
|
|
createTestPng(path.join(session, 'variant-A.png'));
|
|
createTestPng(path.join(session, 'variant-B.png'));
|
|
|
|
const html = generateGalleryHtml(dir);
|
|
expect(html).toContain('draft');
|
|
// No variant should be marked as approved
|
|
expect(html).not.toContain('class="gallery-variant approved"');
|
|
});
|
|
|
|
test('HTML is self-contained (no external dependencies)', () => {
|
|
const dir = path.join(tmpDir, 'self-contained');
|
|
const session = path.join(dir, 'test-20260327');
|
|
fs.mkdirSync(session, { recursive: true });
|
|
createTestPng(path.join(session, 'variant-A.png'));
|
|
|
|
const html = generateGalleryHtml(dir);
|
|
// No external CSS/JS/image links
|
|
expect(html).not.toContain('href="http');
|
|
expect(html).not.toContain('src="http');
|
|
expect(html).not.toContain('<link');
|
|
// All images are base64
|
|
expect(html).toContain('data:image/png;base64,');
|
|
});
|
|
});
|
|
|
|
describe('Gallery with never-overwrite rounds', () => {
|
|
const setMtime = (file: string, seconds: number) => fs.utimesSync(file, seconds, seconds);
|
|
|
|
// Value: protects=bumped names (variant-A-2.png) are listed and approved_path marks the exact image, not the letter;
|
|
// fails_when=the regex drops -N suffixes or marking falls back to the letter when approved_path exists; why_new=#1529 gallery gap; seam=none
|
|
test('lists bumped variants and marks the one approved_path names', () => {
|
|
const session = path.join(tmpDir, 'bumped', 'home-20261003');
|
|
fs.mkdirSync(session, { recursive: true });
|
|
createTestPng(path.join(session, 'variant-A.png'));
|
|
createTestPng(path.join(session, 'variant-A-2.png'));
|
|
fs.writeFileSync(path.join(session, 'approved.json'), JSON.stringify({ approved_variant: 'A', approved_path: 'variant-A-2.png' }));
|
|
expect(listVariantImages(session).map(f => path.basename(f)).sort()).toEqual(['variant-A-2.png', 'variant-A.png']);
|
|
const html = generateGalleryHtml(path.join(tmpDir, 'bumped'));
|
|
expect(html.match(/class="gallery-variant approved"/g)).toHaveLength(1);
|
|
expect(html).toMatch(/class="gallery-variant approved">\s*<img[^>]+alt="Variant A-2"/);
|
|
expect(html).toContain('letters do not identify rounds');
|
|
});
|
|
|
|
// Value: protects=history order is mtime then name, and the current board follows in board-images.json order even when files finished out of order;
|
|
// fails_when=the gallery sorts by name only or ignores board-images.json; why_new=eng gallery semantics; seam=none
|
|
test('orders history by mtime then name, then the board in board-images.json order', () => {
|
|
const session = path.join(tmpDir, 'ordered');
|
|
fs.mkdirSync(session, { recursive: true });
|
|
for (const f of ['variant-B.png', 'variant-A.png']) { createTestPng(path.join(session, f)); setMtime(path.join(session, f), 1_000); }
|
|
const round2 = ['variant-A-2.png', 'variant-B-2.png', 'variant-C-2.png'];
|
|
[3_000, 2_500, 2_000].forEach((t, i) => { createTestPng(path.join(session, round2[i])); setMtime(path.join(session, round2[i]), t); });
|
|
expect(listVariantImages(session).map(f => path.basename(f))).toEqual(
|
|
['variant-A.png', 'variant-B.png', 'variant-C-2.png', 'variant-B-2.png', 'variant-A-2.png']);
|
|
fs.writeFileSync(path.join(session, 'board-images.json'), JSON.stringify(round2.map(f => path.join(session, f))));
|
|
expect(listVariantImages(session).map(f => path.basename(f))).toEqual(
|
|
['variant-A.png', 'variant-B.png', 'variant-A-2.png', 'variant-B-2.png', 'variant-C-2.png']);
|
|
});
|
|
});
|