/** * Focused integration tests for the browser questionnaire server. * Run with: node --test tests/picker-server.test.mjs */ import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { once } from 'node:events'; import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { before, test } from 'node:test'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const serverScript = path.join(root, 'skill/scripts/picker-server.mjs'); const paletteScript = path.join(root, 'skill/scripts/palette.mjs'); const colorModule = pathToFileURL(path.join(root, 'picker/scripts/color.js')).href; const pickerIndex = path.join(root, 'skill/scripts/picker/index.html'); const portBase = 18_500 + (process.pid % 500); const cueManifestFixture = { cues: ['hero-01'], palette: { 'hero-01': { primary: { hex: '#1E4A42', snapped: '#1F4B42', at: [168, 252] }, secondary: { hex: '#8C7251', snapped: '#8D7352', at: [424, 318] }, tertiary: { hex: '#D8A82F', snapped: '#D7A930', at: [702, 190] }, neutral: { hex: '#F2EFE8', snapped: '#F1EEE7', at: [86, 94] }, }, }, }; const fontManifestFixture = { version: 1, specimen: { headline: 'Flowers shaped by hand', body: 'Seasonal stems become arrangements made for one room and one moment.', }, preview: { brand: 'Ha', nav: ['Bouquets', 'Workshops', 'Seasonal', 'About'], navAction: 'Order', menuAction: 'Menu', ctaPrimary: 'Shop stems', ctaSecondary: 'See the studio', proof: ['Same-day pickup', 'Local growers', 'Hand tied', 'Studio open'], sectionTitle: 'Stems in season', sectionBody: [ 'Each arrangement starts with stems', 'chosen the morning it ships.', ], sectionLink: 'Our growers', // Three, the count the artboard draws and the manifest validator requires: // a fourth card makes the whole file fall back to the default pairs. gallery: [ { title: 'Market bunch', meta: 'From $38' }, { title: 'Table vase', meta: 'From $52' }, { title: 'Ceremony', meta: 'From $120' }, ], footerLinks: ['Care guide', 'Delivery', 'Contact', 'Instagram'], footerMark: '© Hanazono', }, pairs: [ { id: 'marcellus-karla', name: 'Atelier Classic', heading: { family: 'Marcellus', weight: 400 }, body: { family: 'Karla', weight: 400 }, why: 'Marcellus echoes the high-contrast lettering observed in the atelier mark.', }, { id: 'bitter-cabin', name: 'Garden Ledger', heading: { family: 'Bitter', weight: 600 }, body: { family: 'Cabin', weight: 400 }, why: 'Bitter gives the seasonal catalog the practical character named in Positioning.', }, ], }; before(() => { if (existsSync(pickerIndex)) return; execFileSync(process.execPath, [path.join(root, 'scripts/build-picker.mjs')], { cwd: root, stdio: 'inherit', }); }); async function createFixture({ fonts = true } = {}) { const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-picker-'))); const cuesDir = path.join(cwd, '.impeccable/visual-cues'); await mkdir(cuesDir, { recursive: true }); await writeFile(path.join(cuesDir, 'hero-01.png'), Buffer.from('fake-png')); await writeFile( path.join(cuesDir, 'cues.json'), `${JSON.stringify(cueManifestFixture)}\n`, ); if (fonts) { await writeFile( path.join(cuesDir, 'fonts.json'), `${JSON.stringify(fontManifestFixture)}\n`, ); } return { cwd, cuesDir }; } async function startPicker(cwd, args = []) { const processHandle = spawn(process.execPath, [serverScript, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], }); processHandle.stdout.setEncoding('utf8'); processHandle.stderr.setEncoding('utf8'); let stdout = ''; let stderr = ''; let settled = false; let resolveUrl; let rejectUrl; const urlPromise = new Promise((resolve, reject) => { resolveUrl = resolve; rejectUrl = reject; }); const timer = setTimeout(() => { if (!settled) rejectUrl(new Error(`Server start timeout. stdout=${stdout} stderr=${stderr}`)); }, 5000); processHandle.stdout.on('data', (chunk) => { stdout += chunk; const firstLine = stdout.split(/\r?\n/)[0]; if (!settled && firstLine.startsWith('PICKER_URL ')) { settled = true; clearTimeout(timer); resolveUrl({ firstLine, url: firstLine.slice('PICKER_URL '.length) }); } }); processHandle.stderr.on('data', (chunk) => { stderr += chunk; }); processHandle.once('error', (error) => { if (!settled) rejectUrl(error); }); processHandle.once('exit', (code) => { if (!settled) rejectUrl(new Error(`Server exited ${code}. stdout=${stdout} stderr=${stderr}`)); }); const started = await urlPromise; return { ...started, processHandle, stdout: () => stdout, stderr: () => stderr, }; } async function waitForExit(processHandle) { if (processHandle.exitCode !== null) return [processHandle.exitCode, processHandle.signalCode]; return once(processHandle, 'exit'); } async function cleanup(t, fixture, server) { t.after(async () => { if (server?.processHandle.exitCode === null) server.processHandle.kill('SIGTERM'); await rm(fixture.cwd, { recursive: true, force: true }); }); } function rawGet(baseUrl, requestPath) { const url = new URL(baseUrl); return new Promise((resolve, reject) => { const request = http.get({ hostname: url.hostname, port: url.port, path: requestPath, }, (response) => { response.resume(); response.once('end', () => resolve(response.statusCode)); }); request.once('error', reject); }); } test('serves picker and cues, writes submission, prints answers, and exits 0', async (t) => { const fixture = await createFixture(); const server = await startPicker(fixture.cwd, ['--port', String(portBase)]); await cleanup(t, fixture, server); assert.equal(server.firstLine, `PICKER_URL ${server.url}`); assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/); const pageResponse = await fetch(`${server.url}/`); assert.equal(pageResponse.status, 200); assert.match(pageResponse.headers.get('content-type'), /^text\/html/); const pageHtml = await pageResponse.text(); assert.match(pageHtml, /data-copy-url/); assert.match(pageHtml, /Open this link in your browser at least 1200px wide/); assert.match(pageHtml, /data-copy-url-value aria-label="Picker URL"><\/code>/); assert.match(pageHtml, /aria-label="Copy link"/); assert.match(pageHtml, />Start