mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* chore: unify product name display to OpenDesign repo-wide Rename the user-facing product name from "Open Design" to "OpenDesign" across documentation, web/desktop UI strings, i18n locales, prompts, plugin/skill/design-system content, changelogs, and specs, matching the landing-page rename already in flight. Deliberately excluded so installed-app identity stays untouched: - packages/release PRODUCT_NAME and everything derived from it (app bundle names, installer/registry identity, updater feeds) - tools/pack, tools/release, apps/packaged, landing page (PR #6983) - mocks/ recorded traces and LICENSE - test fixtures and comments that encode real on-disk install paths (Open Design.app, Program Files\Open Design, AppData paths) Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host test runs over the renamed strings. * fix(web): keep two-letter author monogram for CamelCase brand names authorInitials split words on whitespace only, so the renamed single-token author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper CamelCase boundaries as word breaks so the avatar fallback stays "OD". * fix(release): restore spaced Windows source artifact names in win.ps1 tools/pack still derives builder outputs from the unchanged PRODUCT_NAME ("Open Design"), so the asset script must look for the spaced filenames. Matches the mac/mac-intel/linux asset scripts, which were already kept on the original names. --------- Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2415 lines
95 KiB
TypeScript
2415 lines
95 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import fs, { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import type { Brand } from '@open-design/contracts';
|
||
|
||
import {
|
||
closeDatabase,
|
||
deleteConversation,
|
||
getProject,
|
||
listConversations,
|
||
listMessages,
|
||
listProjects,
|
||
listTabs,
|
||
openDatabase,
|
||
upsertMessage,
|
||
} from '../src/db.js';
|
||
import {
|
||
backfillBrandExtractionTranscriptForProject,
|
||
continueBrandExtraction,
|
||
finalizeBrand,
|
||
readBrandDetail,
|
||
reconcileProgrammaticExtractionTranscript,
|
||
renderBrandPreviewIntoProject,
|
||
startBrandExtraction,
|
||
} from '../src/brands/index.js';
|
||
import { patchMeta } from '../src/brands/store.js';
|
||
import { ensureLogoFallback } from '../src/brands/logo-fallback.js';
|
||
import { brandFromMaterial } from '../src/brands/provisional.js';
|
||
import { deleteUserDesignSystem, listDesignSystems } from '../src/design-systems/index.js';
|
||
import {
|
||
buildBrandSystem,
|
||
defaultSeed,
|
||
defaultThemeAlgorithm,
|
||
deriveTokens,
|
||
seedFromMaterial,
|
||
tokensToThemeJson,
|
||
} from '../src/brands/engine/index.js';
|
||
import type { SeedToken } from '../src/brands/engine/types.js';
|
||
import {
|
||
adoptExistingImagery,
|
||
findImageRefs,
|
||
imageSize,
|
||
type ImagerySlot,
|
||
} from '../src/brands/imagery-fallback.js';
|
||
import { isChallengePage, type PrefetchResult } from '../src/brands/prefetch.js';
|
||
|
||
// Real repo skills root so the bundled brand-kit template resolves.
|
||
const SKILLS_ROOT = path.resolve(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'../../../skills',
|
||
);
|
||
|
||
// Injected logo harvester that does nothing — keeps the engine tests offline
|
||
// (the real fallback fetches the site's icon assets over the network).
|
||
const NO_LOGO_FALLBACK = async () => ({ changed: false });
|
||
|
||
// Injected imagery harvester that does nothing — keeps finalize offline (the
|
||
// real fallback fetches the site's cover/hero images over the network).
|
||
const NO_IMAGERY_FALLBACK = async () => ({ changed: false });
|
||
|
||
// Injected palette/typography harvester that does nothing — keeps the engine
|
||
// tests offline except for cases that explicitly stub a harvester behavior.
|
||
const NO_SEED_FALLBACK = async () => ({ changed: false });
|
||
|
||
function startOfflineBrandExtraction(
|
||
opts: Parameters<typeof startBrandExtraction>[0],
|
||
): ReturnType<typeof startBrandExtraction> {
|
||
return startBrandExtraction({
|
||
seedFallback: NO_SEED_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
...opts,
|
||
});
|
||
}
|
||
|
||
// Regression guard for the seeded brand-extraction prompts. The daemon folds a
|
||
// skill body into the SYSTEM prompt when a project has an active `skillId`; it
|
||
// does NOT register `skills/` as Claude-Code `Skill` / slash commands. A seeded
|
||
// prompt that tells the agent to "use the `brand-extract` skill" is therefore
|
||
// executed as a `Skill {"skill":"brand-extract"}` tool call that has no registry
|
||
// to resolve against and always fails — the red Skill card users hit mid-
|
||
// extraction. The seeded prompt must never instruct loading/invoking a skill.
|
||
function expectNoPhantomSkillCall(prompt: string): void {
|
||
expect(prompt).not.toMatch(/use the `?brand-extract`? skill/i);
|
||
expect(prompt).not.toMatch(/\buse the `[^`]+` skill\b/i);
|
||
expect(prompt).not.toMatch(/\bload the `?brand-extract`? skill\b/i);
|
||
}
|
||
|
||
/** Build a tiny but structurally-valid PNG buffer with the given dimensions so
|
||
* the imagery size gate decodes a real width/height (header-only). */
|
||
function pngBuffer(width: number, height: number): Buffer {
|
||
const buf = Buffer.alloc(33);
|
||
buf.writeUInt32BE(0x89504e47, 0);
|
||
buf.writeUInt32BE(0x0d0a1a0a, 4);
|
||
buf.writeUInt32BE(13, 8);
|
||
buf.write('IHDR', 12, 'ascii');
|
||
buf.writeUInt32BE(width, 16);
|
||
buf.writeUInt32BE(height, 20);
|
||
return buf;
|
||
}
|
||
|
||
// A minimal-but-valid brand.json the agent is expected to have written into the
|
||
// backing project before finalize runs (seven roles, the three required ones).
|
||
const VALID_BRAND = {
|
||
name: 'Acme',
|
||
tagline: 'We make things',
|
||
description: 'Acme makes excellent things for everyone.',
|
||
sourceUrl: 'https://acme.com/',
|
||
logo: { primary: null, alternates: [], notes: '' },
|
||
colors: [
|
||
{ role: 'background', hex: '#f5f4ed', oklch: 'oklch(96% 0.01 90)', name: 'Parchment', usage: 'page background' },
|
||
{ role: 'surface', hex: '#ffffff', oklch: 'oklch(100% 0 0)', name: 'Card', usage: 'cards' },
|
||
{ role: 'foreground', hex: '#141413', oklch: 'oklch(17% 0.005 90)', name: 'Ink', usage: 'text' },
|
||
{ role: 'muted', hex: '#87867f', oklch: 'oklch(60% 0.01 90)', name: 'Stone', usage: 'secondary text' },
|
||
{ role: 'border', hex: '#e8e6dc', oklch: 'oklch(92% 0.01 90)', name: 'Hairline', usage: 'borders' },
|
||
{ role: 'accent', hex: '#d97757', oklch: 'oklch(67% 0.13 40)', name: 'Terracotta', usage: 'CTAs' },
|
||
{ role: 'accent-secondary', hex: '#3d7a4f', oklch: 'oklch(50% 0.09 150)', name: 'Moss', usage: 'success' },
|
||
],
|
||
typography: {
|
||
display: { family: 'Tiempos', fallbacks: ['Georgia', 'serif'], weights: [400, 600] },
|
||
body: { family: 'Inter', fallbacks: ['system-ui'], weights: [400, 700] },
|
||
},
|
||
voice: {
|
||
adjectives: [],
|
||
tone: '',
|
||
messagingPillars: [],
|
||
vocabulary: { use: [], avoid: [] },
|
||
},
|
||
imagery: {
|
||
style: '',
|
||
subjects: [],
|
||
treatment: '',
|
||
avoid: [],
|
||
samples: [],
|
||
},
|
||
layout: {
|
||
radius: '',
|
||
borderWeight: '',
|
||
spacing: '',
|
||
postureRules: [],
|
||
},
|
||
} satisfies Brand;
|
||
|
||
const DESIGN_MD_INPUT = `---
|
||
name: Heritage
|
||
colors:
|
||
primary: "#1A1C1E"
|
||
secondary: "#6C7278"
|
||
tertiary: "#B8422E"
|
||
neutral: "#F7F5F2"
|
||
typography:
|
||
h1:
|
||
fontFamily: Public Sans
|
||
fontSize: 3rem
|
||
body-md:
|
||
fontFamily: Public Sans
|
||
fontSize: 1rem
|
||
rounded:
|
||
sm: 4px
|
||
md: 8px
|
||
components:
|
||
button-primary:
|
||
backgroundColor: "{colors.tertiary}"
|
||
---
|
||
|
||
# Heritage
|
||
|
||
## Overview
|
||
Architectural Minimalism meets Journalistic Gravitas.
|
||
|
||
## Colors
|
||
- **Tertiary (#B8422E):** Boston Clay for interaction.
|
||
`;
|
||
|
||
function programmaticPrefetchResult(url: string): PrefetchResult {
|
||
return {
|
||
url,
|
||
finalUrl: url,
|
||
siteName: 'Acme',
|
||
title: 'Acme',
|
||
description: 'Acme makes excellent things for everyone.',
|
||
colors: [
|
||
{ hex: '#f5f4ed', count: 12 },
|
||
{ hex: '#141413', count: 9 },
|
||
{ hex: '#b8422e', count: 8 },
|
||
{ hex: '#3d7a4f', count: 3 },
|
||
],
|
||
fonts: [{ family: 'Inter', count: 10 }],
|
||
fontFaceFamilies: [],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [],
|
||
headings: ['Excellent things'],
|
||
paragraphs: ['Acme makes useful products for careful teams.'],
|
||
navLabels: ['Product', 'Pricing'],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
thin: false,
|
||
blocked: false,
|
||
materialMd: '# Acme\n\nExcellent things',
|
||
};
|
||
}
|
||
|
||
describe('agent-driven brand extraction engine', () => {
|
||
let tempDir: string;
|
||
let brandsRoot: string;
|
||
let projectsRoot: string;
|
||
let userDesignSystemsRoot: string;
|
||
|
||
it('adopts the brand canvas for the default theme when the brand is explicitly dark-first', () => {
|
||
const darkCanvasBrand: Brand = {
|
||
...VALID_BRAND,
|
||
name: 'OpenDesign',
|
||
colors: [
|
||
{ role: 'background', hex: '#050505', oklch: 'oklch(14% 0 0)', name: 'Black', usage: 'source hero background' },
|
||
{ role: 'surface', hex: '#0a0a0a', oklch: 'oklch(17% 0 0)', name: 'Panel', usage: 'source cards' },
|
||
{ role: 'foreground', hex: '#f4f4f4', oklch: 'oklch(96% 0 0)', name: 'White', usage: 'source text' },
|
||
{ role: 'accent', hex: '#56fe13', oklch: 'oklch(86% 0.29 142)', name: 'Signal Green', usage: 'primary actions' },
|
||
],
|
||
};
|
||
|
||
const system = buildBrandSystem(darkCanvasBrand);
|
||
|
||
// A brand that explicitly carries a dark background role plus a light
|
||
// foreground role is dark-first: the default theme keeps its canvas
|
||
// instead of clamping to the light Ant baseline.
|
||
expect(system.themes.default.colorBgContainer).toBe('#050505');
|
||
expect(system.themes.dark.colorBgContainer).toBe('#050505');
|
||
// Neutral text derives from the brand foreground over the dark canvas —
|
||
// it must read light, not the light-theme #1f1f1f.
|
||
expect(parseInt(system.themes.default.colorText.slice(1, 3), 16)).toBeGreaterThan(180);
|
||
expect(system.files['kit.html']).toContain('--brand-color-bg-container: #050505;');
|
||
expect(system.files['kit.html']).not.toContain('--brand-color-bg-container: #ffffff;');
|
||
expect(system.files['kit.dark.html']).toContain('--brand-color-bg-container: #050505;');
|
||
// The exported ConfigProvider artifact must carry the SAME effective
|
||
// algorithm as the tokens/CSS/kit above — otherwise a consumer applies the
|
||
// light algorithm to a dark canvas.
|
||
expect(JSON.parse(system.files['theme.json'] ?? '').algorithm).toBe('dark');
|
||
});
|
||
|
||
it('still falls back to the light default theme when brand neutrals are ambiguous', () => {
|
||
const midGrayBrand: Brand = {
|
||
...VALID_BRAND,
|
||
name: 'OpenDesign',
|
||
colors: [
|
||
{ role: 'background', hex: '#808080', oklch: 'oklch(60% 0 0)', name: 'Gray', usage: 'source background' },
|
||
{ role: 'foreground', hex: '#f4f4f4', oklch: 'oklch(96% 0 0)', name: 'White', usage: 'source text' },
|
||
{ role: 'accent', hex: '#56fe13', oklch: 'oklch(86% 0.29 142)', name: 'Signal Green', usage: 'primary actions' },
|
||
],
|
||
};
|
||
|
||
const system = buildBrandSystem(midGrayBrand);
|
||
|
||
// A mid-gray canvas is not a confident dark-first signal; keep the light
|
||
// baseline exactly as before.
|
||
expect(system.themes.default.colorBgContainer).toBe('#ffffff');
|
||
expect(system.themes.dark.colorBgContainer).toBe('#141414');
|
||
// ...and the exported ConfigProvider artifact stays on the light algorithm.
|
||
expect(JSON.parse(system.files['theme.json'] ?? '').algorithm).toBe('default');
|
||
});
|
||
|
||
it('drops scraped CSS source junk from font families instead of corrupting the token block', () => {
|
||
// Extractors sometimes scrape CSS *source text* instead of a real family —
|
||
// e.g. Tailwind v4's `--theme(--default-font-family` from aliyun.com. The
|
||
// unbalanced `(` inside the emitted `--brand-font-family` value swallows
|
||
// every later `:root` declaration (sizes, control heights, radii), which
|
||
// renders the whole component kit unstyled (issue: kit shows UA serif text
|
||
// with collapsed buttons while colors declared earlier still work).
|
||
const junkFontBrand: Brand = {
|
||
...VALID_BRAND,
|
||
name: 'Junk Fonts',
|
||
typography: {
|
||
display: { family: '--theme(--default-font-family', fallbacks: ['system-ui'], weights: [400, 700] },
|
||
body: { family: '--theme(--default-font-family', fallbacks: ['system-ui'], weights: [400, 700] },
|
||
},
|
||
};
|
||
|
||
const system = buildBrandSystem(junkFontBrand);
|
||
|
||
// The junk never reaches the seed or any emitted document.
|
||
expect(system.seed.fontFamily).not.toContain('--theme(');
|
||
for (const file of ['kit.html', 'kit.dark.html', 'variables.css', 'artifacts/landing.html', 'index.html']) {
|
||
expect(system.files[file], file).not.toContain('--theme(');
|
||
}
|
||
// The declared custom property keeps balanced parens so the declarations
|
||
// after it (sizes, control heights) survive CSS parsing.
|
||
const famLine = /--brand-font-family:([^\n]*)/.exec(system.files['kit.html'] ?? '')?.[1] ?? '';
|
||
expect(famLine).not.toBe('');
|
||
expect((famLine.match(/\(/g) ?? []).length).toBe((famLine.match(/\)/g) ?? []).length);
|
||
// Renderable fallbacks survive the sanitization.
|
||
expect(system.seed.fontFamily).toContain('system-ui');
|
||
});
|
||
|
||
it('keeps theme.json algorithm consistent with the derived theme under a background-only seed override', () => {
|
||
// Locks the rebuildSystem seed-override path (sanitizeSeedOverrides →
|
||
// reassembleWithSeed → tokensToThemeJson(seed, defaultThemeAlgorithm(seed))):
|
||
// a background-only override on an otherwise light brand must NOT be treated
|
||
// as dark-first, or theme.json would export algorithm:"dark" while the seed
|
||
// still carries a dark colorTextBase — the ConfigProvider mismatch.
|
||
const bgOnlyDark: SeedToken = { ...defaultSeed, colorBgBase: '#050505' }; // colorTextBase stays #000000
|
||
expect(defaultThemeAlgorithm(bgOnlyDark)).toBe('default');
|
||
// The derived default theme clamps back to the light canvas...
|
||
expect(deriveTokens(bgOnlyDark, 'default').colorBgContainer).toBe('#ffffff');
|
||
// ...and the exported ConfigProvider algorithm matches it (no dark/light split).
|
||
expect(JSON.parse(tokensToThemeJson(bgOnlyDark, defaultThemeAlgorithm(bgOnlyDark))).algorithm).toBe('default');
|
||
|
||
// A full override supplying BOTH a dark canvas and a light foreground DOES
|
||
// opt into dark-first — consistently across the derived theme and export.
|
||
const fullDark: SeedToken = { ...defaultSeed, colorBgBase: '#050505', colorTextBase: '#f4f4f4' };
|
||
expect(defaultThemeAlgorithm(fullDark)).toBe('dark');
|
||
expect(deriveTokens(fullDark, 'default').colorBgContainer).toBe('#050505');
|
||
expect(JSON.parse(tokensToThemeJson(fullDark, defaultThemeAlgorithm(fullDark))).algorithm).toBe('dark');
|
||
});
|
||
|
||
it('keeps programmatic dark-site material on a light default seed', () => {
|
||
const seed = seedFromMaterial({
|
||
colors: [
|
||
{ hex: '#050505', count: 120 },
|
||
{ hex: '#56fe13', count: 28 },
|
||
],
|
||
fonts: [{ family: 'Albert Sans', count: 12 }],
|
||
fontFaceFamilies: [],
|
||
} as unknown as PrefetchResult);
|
||
|
||
expect(seed.colorBgBase).toBe('#ffffff');
|
||
expect(seed.colorTextBase).toBe('#000000');
|
||
expect(deriveTokens(seed, 'default').colorBgContainer).toBe('#ffffff');
|
||
expect(deriveTokens(seed, 'dark').colorBgContainer).toBe('#141414');
|
||
});
|
||
|
||
beforeEach(() => {
|
||
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-brand-engine-'));
|
||
brandsRoot = path.join(tempDir, 'brands');
|
||
projectsRoot = path.join(tempDir, 'projects');
|
||
userDesignSystemsRoot = path.join(tempDir, 'user-design-systems');
|
||
mkdirSync(brandsRoot, { recursive: true });
|
||
mkdirSync(projectsRoot, { recursive: true });
|
||
mkdirSync(userDesignSystemsRoot, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
closeDatabase();
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('prefers source-backed human brand tokens over script/debug color noise', () => {
|
||
const brand = brandFromMaterial({
|
||
url: 'https://open-design.ai/',
|
||
finalUrl: 'https://open-design.ai/',
|
||
siteName: 'OpenDesign',
|
||
title: 'OpenDesign',
|
||
description: 'OpenDesign design system.',
|
||
colors: [
|
||
{ hex: '#262626', count: 19, sources: ['css-var:--ink'] },
|
||
{ hex: '#15140f', count: 15, sources: ['css-var:--shadow-ink'] },
|
||
{ hex: '#d8ffb5', count: 7, sources: ['selector:.hero-discord-pill:hover prop:background'] },
|
||
{ hex: '#ffa500', count: 6, sources: ['prop:fillStyle selector:matter-debug-canvas'] },
|
||
{ hex: '#83ff3b', count: 3, sources: ['css-var:--coral-soft'] },
|
||
{ hex: '#63fe13', count: 2, sources: ['css-var:--coral', 'css-var:--mustard'] },
|
||
{ hex: '#ffffff', count: 46, extreme: true, sources: ['css-var:--bone'] },
|
||
{ hex: '#000000', count: 15, extreme: true, sources: ['prop:box-shadow'] },
|
||
],
|
||
fonts: [{ family: 'Albert Sans', count: 2 }],
|
||
fontFaceFamilies: ['Albert Sans'],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [],
|
||
headings: ['OpenDesign The Open-source Claude Design alternative'],
|
||
paragraphs: ['OpenDesign is a local-first design platform.'],
|
||
navLabels: [],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
thin: false,
|
||
blocked: false,
|
||
materialMd: '',
|
||
}, 'https://open-design.ai/');
|
||
|
||
expect(brand.colors.find((color) => color.role === 'accent')?.hex).toBe('#63fe13');
|
||
expect(brand.typography.body.family).toBe('Albert Sans');
|
||
});
|
||
|
||
it('allows a dominant near-black system to beat generic generated blue effects', () => {
|
||
const brand = brandFromMaterial({
|
||
url: 'https://refly.ai/',
|
||
finalUrl: 'https://refly.ai/',
|
||
siteName: 'Refly.ai-Vibe Workflow for Non-Technical Users',
|
||
title: 'Refly.ai-Vibe Workflow for Non-Technical Users',
|
||
description: 'Refly.ai empowers non-technical creators through natural language and a visual canvas.',
|
||
colors: [
|
||
{ hex: '#d5dbe6', count: 191, sources: ['css-var:--token-f195ea74-7512-4096-8d91-0e7c7e10d0ab'] },
|
||
{ hex: '#cfe7ff', count: 123, sources: ['prop:box-shadow selector:.framer-card'] },
|
||
{ hex: '#0099ff', count: 117, sources: ['prop:background-color selector:.framer-generated-effect'] },
|
||
{ hex: '#10131c', count: 66, sources: ['css-var:--token-f213e283-24d0-40a3-a2dc-bca1da07b971'] },
|
||
{ hex: '#0e9f77', count: 18, sources: ['logo-svg:header-img-3.svg'] },
|
||
{ hex: '#04070d', count: 174, extreme: true, sources: ['css-var:--token-eb09dbbf-ef85-4b7f-81a5-44e9b062efb7'] },
|
||
{ hex: '#ffffff', count: 157, extreme: true, sources: ['css-var:--token-a85af9cb-7834-4006-a277-2dd1295ae376'] },
|
||
{ hex: '#000000', count: 156, extreme: true, sources: ['prop:background selector:footer'] },
|
||
],
|
||
fonts: [{ family: 'Inter', count: 157 }],
|
||
fontFaceFamilies: ['Inter'],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [],
|
||
headings: ['The World’s First Vibe Workflow Platform'],
|
||
paragraphs: ['Refly.ai empowers non-technical creators through natural language and a visual canvas.'],
|
||
navLabels: [],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
thin: false,
|
||
blocked: false,
|
||
materialMd: '',
|
||
}, 'https://refly.ai/');
|
||
|
||
expect(brand.colors.find((color) => color.role === 'accent')?.hex).toBe('#04070d');
|
||
expect(brand.colors.find((color) => color.role === 'accent-secondary')?.hex).toBe('#0e9f77');
|
||
});
|
||
|
||
it('startBrandExtraction reserves the brand and seeds a live brand.html tab', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
// URL is normalized to https and the brand starts in `extracting`.
|
||
expect(result.sourceUrl).toBe('https://acme.com/');
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('extracting');
|
||
expect(detail?.meta.projectId).toBe(result.projectId);
|
||
|
||
// The backing project exists and carries the seeded extraction prompt.
|
||
const project = getProject(db, result.projectId);
|
||
expect(project).toBeTruthy();
|
||
expect(project?.metadata?.kind).toBe('brand');
|
||
expect(project?.pendingPrompt ?? '').toContain('DESIGN SYSTEM ENRICHMENT');
|
||
expect(project?.pendingPrompt ?? '').toContain(`od brand preview ${result.id}`);
|
||
|
||
// brand.html is seeded as the active tab; the site stays as a secondary
|
||
// browser tab the user can use to clear an anti-bot wall by hand.
|
||
const brandHtmlPath = path.join(projectsRoot, result.projectId, 'brand.html');
|
||
expect(existsSync(brandHtmlPath)).toBe(true);
|
||
const seeded = readFileSync(brandHtmlPath, 'utf8');
|
||
expect(seeded).toContain('"status":"extracting"');
|
||
expect(seeded).toContain('acme.com');
|
||
|
||
const tabs = listTabs(db, result.projectId) as {
|
||
active: string | null;
|
||
browserTabs?: Array<{ url?: string }>;
|
||
};
|
||
expect(tabs.active).toBe('brand.html');
|
||
expect(tabs.browserTabs?.[0]?.url).toBe('https://acme.com/');
|
||
});
|
||
|
||
it('localizes the seeded brand.html copy from the creation locale', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
locale: 'zh-CN',
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const html = readFileSync(path.join(projectsRoot, result.projectId, 'brand.html'), 'utf8');
|
||
expect(html).toContain('<html lang="zh-CN">');
|
||
expect(html).toContain('<title>品牌设计体系</title>');
|
||
expect(html).toContain('"logo":"标志"');
|
||
expect(html).toContain('"brandReady":"设计体系已就绪"');
|
||
});
|
||
|
||
it('rolls back the reserved draft design system when brand startup fails', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
closeDatabase();
|
||
|
||
await expect(startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
})).rejects.toThrow();
|
||
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems).toHaveLength(0);
|
||
});
|
||
|
||
it('rolls back brand startup state when setup fails after project insert', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
await expect(startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
randomId: () => {
|
||
throw new Error('conversation id failed');
|
||
},
|
||
})).rejects.toThrow('conversation id failed');
|
||
|
||
expect(listProjects(db)).toHaveLength(0);
|
||
expect(readdirSync(projectsRoot)).toEqual([]);
|
||
expect(readdirSync(brandsRoot)).toEqual([]);
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems).toHaveLength(0);
|
||
});
|
||
|
||
it('rolls back the scoped draft when the project Workspace binding fails', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const deleteDraftDesignSystem = vi.fn(deleteUserDesignSystem);
|
||
const bindCreatedProject = vi.fn(() => {
|
||
throw new Error('workspace project binding failed');
|
||
});
|
||
const options = {
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
deleteUserDesignSystem: deleteDraftDesignSystem,
|
||
bindCreatedProject,
|
||
} as Parameters<typeof startBrandExtraction>[0] & {
|
||
bindCreatedProject: (projectId: string) => void;
|
||
};
|
||
|
||
await expect(startOfflineBrandExtraction(options))
|
||
.rejects.toThrow('workspace project binding failed');
|
||
|
||
expect(bindCreatedProject).toHaveBeenCalledTimes(1);
|
||
expect(deleteDraftDesignSystem).toHaveBeenCalledTimes(1);
|
||
expect(listProjects(db)).toHaveLength(0);
|
||
expect(readdirSync(projectsRoot)).toEqual([]);
|
||
expect(readdirSync(brandsRoot)).toEqual([]);
|
||
await expect(listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
})).resolves.toHaveLength(0);
|
||
});
|
||
|
||
it('rolls back brand startup state when design-md staging fails before draft reservation', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const mkdirSyncOriginal = fs.mkdirSync.bind(fs);
|
||
const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(((target, options) => {
|
||
if (String(target).endsWith(`${path.sep}context`)) {
|
||
throw new Error('design-md staging failed');
|
||
}
|
||
return mkdirSyncOriginal(target, options as fs.MakeDirectoryOptions);
|
||
}) as typeof fs.mkdirSync);
|
||
|
||
try {
|
||
await expect(startOfflineBrandExtraction({
|
||
designMd: DESIGN_MD_INPUT,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
})).rejects.toThrow('design-md staging failed');
|
||
} finally {
|
||
mkdirSpy.mockRestore();
|
||
}
|
||
|
||
expect(listProjects(db)).toHaveLength(0);
|
||
expect(readdirSync(projectsRoot)).toEqual([]);
|
||
expect(readdirSync(brandsRoot)).toEqual([]);
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems).toHaveLength(0);
|
||
});
|
||
|
||
it('seeds a running chat transcript immediately and completes it when the programmatic pass returns ready', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const startedBeforeRequest = Date.now();
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
designMd: DESIGN_MD_INPUT,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
const returnedAfterRequest = Date.now();
|
||
|
||
expect(result.status).toBe('extracting');
|
||
expect(result.designSystemId).toMatch(/^user:/);
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
const initialMessages = listMessages(db, result.conversationId);
|
||
expect(initialMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(initialMessages[0]?.content).toContain('pasted DESIGN.md');
|
||
expect(initialMessages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
expect(initialMessages[1]?.agentId).toBe('claude');
|
||
expect(initialMessages[1]?.agentName).toBe('Claude');
|
||
expect(initialMessages[1]?.runStatus).toBe('running');
|
||
expect(initialMessages[0]?.createdAt).toBe(initialMessages[1]?.startedAt);
|
||
expect(initialMessages[1]?.startedAt).toBeGreaterThanOrEqual(startedBeforeRequest);
|
||
expect(initialMessages[1]?.endedAt).toBeUndefined();
|
||
expect(getProject(db, result.projectId)?.pendingPrompt).toBeUndefined();
|
||
|
||
await backgroundExtraction;
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const messages = listMessages(db, result.conversationId);
|
||
if (messages.some((message) => message.content.includes('Programmatic extraction finished'))) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
const endedAfterCompletion = Date.now();
|
||
|
||
const completedMessages = listMessages(db, result.conversationId);
|
||
expect(completedMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(completedMessages[1]?.content).toContain('Programmatic extraction finished');
|
||
expect(completedMessages[1]?.agentId).toBe('claude');
|
||
expect(completedMessages[1]?.agentName).toBe('Claude');
|
||
expect(completedMessages[1]?.runStatus).toBe('succeeded');
|
||
expect(completedMessages[0]?.createdAt).toBe(completedMessages[1]?.startedAt);
|
||
expect(completedMessages[1]?.startedAt).toBe(initialMessages[1]?.startedAt);
|
||
expect(completedMessages[1]?.endedAt).toBeGreaterThanOrEqual(returnedAfterRequest);
|
||
expect(completedMessages[1]?.endedAt).toBeLessThanOrEqual(endedAfterCompletion);
|
||
expect(completedMessages[1]?.endedAt).toBeGreaterThanOrEqual(completedMessages[1]?.startedAt ?? 0);
|
||
const producedFiles = (Array.isArray(completedMessages[1]?.producedFiles)
|
||
? completedMessages[1].producedFiles
|
||
: []) as Array<{ name?: unknown }>;
|
||
const producedNames = producedFiles
|
||
.map((file) => file.name)
|
||
.filter((name): name is string => typeof name === 'string');
|
||
expect(producedNames).toContain('brand.html');
|
||
expect(producedNames.some((name: string) => name.startsWith('system/'))).toBe(true);
|
||
expect(producedNames.length).toBeGreaterThan(1);
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('ready');
|
||
expect(detail?.meta.designSystemId?.startsWith('user:')).toBe(true);
|
||
});
|
||
|
||
it('keeps the programmatic transcript when other messages arrive before background completion', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let releasePrefetch!: () => void;
|
||
const prefetchGate = new Promise<void>((resolve) => {
|
||
releasePrefetch = resolve;
|
||
});
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async (url) => {
|
||
await prefetchGate;
|
||
return programmaticPrefetchResult(url);
|
||
},
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
expect(result.status).toBe('extracting');
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
const initialMessages = listMessages(db, result.conversationId);
|
||
expect(initialMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(initialMessages[0]?.content).toContain('https://acme.com/');
|
||
expect(initialMessages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
expect(initialMessages[1]?.agentId).toBe('claude');
|
||
expect(initialMessages[1]?.runStatus).toBe('running');
|
||
expect(initialMessages[1]?.startedAt).toBe(initialMessages[0]?.createdAt);
|
||
|
||
upsertMessage(db, result.conversationId, {
|
||
id: 'manual-user-message',
|
||
role: 'user',
|
||
content: 'hello while extraction is still running',
|
||
});
|
||
|
||
releasePrefetch();
|
||
await backgroundExtraction;
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const messages = listMessages(db, result.conversationId);
|
||
if (messages.some((message) => message.content.includes('Programmatic extraction finished'))) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
|
||
const messages = listMessages(db, result.conversationId);
|
||
expect(messages.some((message) => message.content === 'hello while extraction is still running')).toBe(true);
|
||
const assistant = messages.find((message) => message.content.includes('Programmatic extraction finished'));
|
||
expect(assistant?.role).toBe('assistant');
|
||
expect(assistant?.agentId).toBe('claude');
|
||
expect(assistant?.runStatus).toBe('succeeded');
|
||
expect(assistant?.producedFiles?.length).toBeGreaterThan(1);
|
||
});
|
||
|
||
it('stops the programmatic pass without finalizing or seeding a fallback run prompt', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const controller = new AbortController();
|
||
let releasePrefetch!: () => void;
|
||
const prefetchGate = new Promise<void>((resolve) => {
|
||
releasePrefetch = resolve;
|
||
});
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async (url) => {
|
||
await prefetchGate;
|
||
return programmaticPrefetchResult(url);
|
||
},
|
||
programmaticAbortSignal: controller.signal,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
expect(getProject(db, result.projectId)?.pendingPrompt).toBeUndefined();
|
||
controller.abort();
|
||
releasePrefetch();
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('extracting');
|
||
// Entity-first: the user:<id> design system exists as a DRAFT from the start
|
||
// (so it shows under "Your systems" and stays editable), but the brand is
|
||
// not finalized — the draft was never promoted to published.
|
||
expect(detail?.meta.designSystemId).toMatch(/^user:/);
|
||
const stoppedSystems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(stoppedSystems.find((s) => s.id === detail?.meta.designSystemId)?.status).toBe('draft');
|
||
expect(getProject(db, result.projectId)?.pendingPrompt).toBeUndefined();
|
||
const messages = listMessages(db, result.conversationId);
|
||
expect(messages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(messages[1]?.runStatus).toBe('running');
|
||
expect(messages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
});
|
||
|
||
// Regression for the "Spotify" P1: a blocked / give-up origin used to leave the
|
||
// synthetic "AMR · Working" row counting up forever while the brand stayed
|
||
// `extracting`. The give-up now retires the row into the actionable terminal so
|
||
// the user sees a next step instead of an infinite clock.
|
||
it('retires the synthetic row into an actionable terminal when the programmatic pass gives up', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'blocked.example',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async () => null,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
|
||
const messages = listMessages(db, result.conversationId);
|
||
expect(messages[1]?.runStatus).toBe('failed');
|
||
expect(messages[1]?.endedAt).toBeGreaterThan(0);
|
||
expect(messages[1]?.content).toContain('needs a hand');
|
||
// The agent fallback still takes over from the scaffold.
|
||
expect(getProject(db, result.projectId)?.pendingPrompt).toContain('DESIGN SYSTEM EXTRACTION');
|
||
// The automatic pass is terminal; the user can restart it explicitly or use
|
||
// the Browser/agent fallback from the saved draft.
|
||
expect(readBrandDetail(brandsRoot, result.id)?.meta.status).toBe('failed');
|
||
});
|
||
|
||
it('continues an incomplete programmatic extraction in the same project and design system', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let firstBackground: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'blocked.example',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async () => null,
|
||
onBackgroundExtraction: (settled) => {
|
||
firstBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
if (!firstBackground) throw new Error('expected background extraction promise');
|
||
await firstBackground;
|
||
|
||
const draftDesignSystemId = readBrandDetail(brandsRoot, result.id)?.meta.designSystemId;
|
||
expect(draftDesignSystemId).toMatch(/^user:/);
|
||
expect(listMessages(db, result.conversationId).map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
|
||
let retryBackground: Promise<unknown> | null = null;
|
||
const retry = await continueBrandExtraction({
|
||
id: result.id,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
prefetch: async (url) => programmaticPrefetchResult(url),
|
||
onBackgroundExtraction: (settled) => {
|
||
retryBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
expect(retry.projectId).toBe(result.projectId);
|
||
expect(retry.conversationId).toBe(result.conversationId);
|
||
expect(retry.designSystemId).toBe(draftDesignSystemId);
|
||
expect(retry.status).toBe('extracting');
|
||
const retryStartedMessages = listMessages(db, result.conversationId);
|
||
expect(retryStartedMessages.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'assistant']);
|
||
expect(retryStartedMessages[3]?.runStatus).toBe('running');
|
||
expect(retryStartedMessages[3]?.content).toContain('Programmatic design-system extraction started');
|
||
|
||
if (!retryBackground) throw new Error('expected retry background extraction promise');
|
||
await retryBackground;
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const messages = listMessages(db, result.conversationId);
|
||
if (messages.some((message) => message.content.includes('Programmatic extraction finished'))) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
|
||
const completed = readBrandDetail(brandsRoot, result.id);
|
||
expect(completed?.meta.status).toBe('ready');
|
||
expect(completed?.meta.designSystemId).toBe(draftDesignSystemId);
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems.filter((system) => system.id === draftDesignSystemId)).toHaveLength(1);
|
||
const messages = listMessages(db, result.conversationId);
|
||
expect(messages[3]?.runStatus).toBe('succeeded');
|
||
});
|
||
|
||
it('ignores a stale programmatic attempt that resumes after a newer retry finalizes', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let releaseFirstPrefetch!: () => void;
|
||
const firstPrefetchGate = new Promise<void>((resolve) => {
|
||
releaseFirstPrefetch = resolve;
|
||
});
|
||
let firstBackground: Promise<unknown> | null = null;
|
||
const staleMaterial = (url: string): PrefetchResult => ({
|
||
...programmaticPrefetchResult(url),
|
||
siteName: 'Staleco',
|
||
title: 'Staleco',
|
||
description: 'Staleco should never overwrite the retry.',
|
||
headings: ['Staleco stale attempt'],
|
||
paragraphs: ['Staleco resumed after a newer retry completed.'],
|
||
materialMd: '# Staleco\n\nStale attempt',
|
||
});
|
||
const retryMaterial = (url: string): PrefetchResult => ({
|
||
...programmaticPrefetchResult(url),
|
||
siteName: 'Retryco',
|
||
title: 'Retryco',
|
||
description: 'Retryco is the newer successful retry.',
|
||
headings: ['Retryco retry attempt'],
|
||
paragraphs: ['Retryco completed before the original attempt resumed.'],
|
||
materialMd: '# Retryco\n\nNewer retry attempt',
|
||
});
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async (url) => {
|
||
await firstPrefetchGate;
|
||
return staleMaterial(url);
|
||
},
|
||
onBackgroundExtraction: (settled) => {
|
||
firstBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
const firstAttemptId = readBrandDetail(brandsRoot, result.id)?.meta.extractionAttemptId;
|
||
expect(firstAttemptId).toBeTruthy();
|
||
|
||
let retryBackground: Promise<unknown> | null = null;
|
||
await continueBrandExtraction({
|
||
id: result.id,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
prefetch: async (url) => retryMaterial(url),
|
||
onBackgroundExtraction: (settled) => {
|
||
retryBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
const retryAttemptId = readBrandDetail(brandsRoot, result.id)?.meta.extractionAttemptId;
|
||
expect(retryAttemptId).toBeTruthy();
|
||
expect(retryAttemptId).not.toBe(firstAttemptId);
|
||
|
||
if (!retryBackground) throw new Error('expected retry background extraction promise');
|
||
await retryBackground;
|
||
expect(readBrandDetail(brandsRoot, result.id)?.brand?.name).toBe('Retryco');
|
||
|
||
releaseFirstPrefetch();
|
||
if (!firstBackground) throw new Error('expected first background extraction promise');
|
||
await firstBackground;
|
||
|
||
const completed = readBrandDetail(brandsRoot, result.id);
|
||
expect(completed?.meta.status).toBe('ready');
|
||
expect(completed?.meta.extractionAttemptId).toBe(retryAttemptId);
|
||
expect(completed?.brand?.name).toBe('Retryco');
|
||
expect(getProject(db, result.projectId)?.name).toBe('Retryco Design System');
|
||
});
|
||
|
||
it('continues a brand extraction with a fresh conversation when the recorded one was deleted', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let firstBackground: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'blocked.example',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async () => null,
|
||
onBackgroundExtraction: (settled) => {
|
||
firstBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
if (!firstBackground) throw new Error('expected background extraction promise');
|
||
await firstBackground;
|
||
|
||
const draftDesignSystemId = readBrandDetail(brandsRoot, result.id)?.meta.designSystemId;
|
||
deleteConversation(db, result.conversationId);
|
||
expect(listConversations(db, result.projectId).some((conversation) => (
|
||
conversation.id === result.conversationId
|
||
))).toBe(false);
|
||
|
||
const ids = ['retry-conversation', 'retry-user-message', 'retry-assistant-message'];
|
||
let idIndex = 0;
|
||
let retryBackground: Promise<unknown> | null = null;
|
||
const retry = await continueBrandExtraction({
|
||
id: result.id,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
randomId: () => ids[idIndex++] ?? `retry-extra-${idIndex}`,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
prefetch: async (url) => programmaticPrefetchResult(url),
|
||
onBackgroundExtraction: (settled) => {
|
||
retryBackground = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
expect(retry.projectId).toBe(result.projectId);
|
||
expect(retry.conversationId).toBe('retry-conversation');
|
||
expect(retry.conversationId).not.toBe(result.conversationId);
|
||
expect(retry.designSystemId).toBe(draftDesignSystemId);
|
||
expect(readBrandDetail(brandsRoot, result.id)?.meta.conversationId).toBe('retry-conversation');
|
||
const retryStartedMessages = listMessages(db, retry.conversationId);
|
||
expect(retryStartedMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(retryStartedMessages[1]?.runStatus).toBe('running');
|
||
|
||
if (!retryBackground) throw new Error('expected retry background extraction promise');
|
||
await retryBackground;
|
||
|
||
const completed = readBrandDetail(brandsRoot, result.id);
|
||
expect(completed?.meta.status).toBe('ready');
|
||
expect(completed?.meta.designSystemId).toBe(draftDesignSystemId);
|
||
expect(listMessages(db, retry.conversationId)[1]?.runStatus).toBe('succeeded');
|
||
});
|
||
|
||
// Regression for the "Whole Foods" P1: a brand that finalizes AFTER the stall
|
||
// checkpoint already posted the "needs a hand" card used to leave the row stuck
|
||
// `running`. A finalize now authoritatively overwrites whatever the row showed
|
||
// with `succeeded`, and that success is never downgraded afterwards.
|
||
it('overwrites a stalled row with success on finalize and never downgrades it', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async () => null,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
expect(listMessages(db, result.conversationId)[1]?.runStatus).toBe('failed');
|
||
|
||
// Simulate the brand finalizing `ready` later (heavy site / agent finished).
|
||
patchMeta(brandsRoot, result.id, { status: 'ready', designSystemId: 'user:acme-late' });
|
||
await reconcileProgrammaticExtractionTranscript({
|
||
db,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
brandId: result.id,
|
||
outcome: 'succeeded',
|
||
});
|
||
const afterSuccess = listMessages(db, result.conversationId)[1];
|
||
expect(afterSuccess?.runStatus).toBe('succeeded');
|
||
expect(afterSuccess?.content).toContain('user:acme-late');
|
||
|
||
// A later stall/give-up reconcile must NOT downgrade a recorded success.
|
||
await reconcileProgrammaticExtractionTranscript({
|
||
db,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
brandId: result.id,
|
||
outcome: 'needs_attention',
|
||
});
|
||
expect(listMessages(db, result.conversationId)[1]?.runStatus).toBe('succeeded');
|
||
});
|
||
|
||
// Models the HTTP cancel route: a deliberate Stop on a still-running pass
|
||
// retires the row into the `canceled` terminal so Stop visibly works.
|
||
it('reconciles a still-running row to canceled on user stop', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const controller = new AbortController();
|
||
let releasePrefetch!: () => void;
|
||
const prefetchGate = new Promise<void>((resolve) => {
|
||
releasePrefetch = resolve;
|
||
});
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async (url) => {
|
||
await prefetchGate;
|
||
return programmaticPrefetchResult(url);
|
||
},
|
||
programmaticAbortSignal: controller.signal,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
expect(listMessages(db, result.conversationId)[1]?.runStatus).toBe('running');
|
||
|
||
controller.abort();
|
||
releasePrefetch();
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
// The engine abort path leaves the row running; the cancel route owns the flip.
|
||
await reconcileProgrammaticExtractionTranscript({
|
||
db,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
brandId: result.id,
|
||
outcome: 'stopped',
|
||
});
|
||
const stopped = listMessages(db, result.conversationId)[1];
|
||
expect(stopped?.runStatus).toBe('canceled');
|
||
expect(stopped?.content).toContain('stopped');
|
||
});
|
||
|
||
it('lets user stop overwrite an earlier stalled programmatic transcript row', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const controller = new AbortController();
|
||
let releasePrefetch!: () => void;
|
||
const prefetchGate = new Promise<void>((resolve) => {
|
||
releasePrefetch = resolve;
|
||
});
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
userDesignSystemsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
prefetch: async (url) => {
|
||
await prefetchGate;
|
||
return programmaticPrefetchResult(url);
|
||
},
|
||
programmaticAbortSignal: controller.signal,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
await reconcileProgrammaticExtractionTranscript({
|
||
db,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
brandId: result.id,
|
||
outcome: 'needs_attention',
|
||
});
|
||
const stalled = listMessages(db, result.conversationId)[1];
|
||
expect(stalled?.runStatus).toBe('failed');
|
||
expect(stalled?.content).toContain('needs a hand');
|
||
expect(stalled?.content).toContain('<od-card type="brand-browser-assist">');
|
||
expect(stalled?.content).toContain('"browserTabId":"__browser__:1"');
|
||
|
||
controller.abort();
|
||
releasePrefetch();
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
await reconcileProgrammaticExtractionTranscript({
|
||
db,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
brandId: result.id,
|
||
outcome: 'stopped',
|
||
});
|
||
|
||
const stopped = listMessages(db, result.conversationId)[1];
|
||
expect(stopped?.runStatus).toBe('canceled');
|
||
expect(stopped?.content).toContain('stopped');
|
||
});
|
||
|
||
it('backfills a programmatic transcript for empty legacy brand conversations', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
expect(listMessages(db, result.conversationId)).toEqual([]);
|
||
const project = getProject(db, result.projectId);
|
||
if (!project) throw new Error('expected project');
|
||
let seq = 0;
|
||
|
||
await backfillBrandExtractionTranscriptForProject({
|
||
db,
|
||
conversationId: result.conversationId,
|
||
randomId: () => `backfill-message-${seq += 1}`,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
project,
|
||
transcriptAgent: { agentId: 'claude', agentName: 'Claude' },
|
||
});
|
||
|
||
const messages = listMessages(db, result.conversationId);
|
||
expect(messages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(messages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
expect(messages[1]?.agentId).toBe('claude');
|
||
expect(messages[1]?.runStatus).toBe('running');
|
||
});
|
||
|
||
it('rejects a non-http(s) URL', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
await expect(
|
||
startOfflineBrandExtraction({ url: 'ftp://nope', brandsRoot, projectsRoot, skillsRoot: SKILLS_ROOT, db }),
|
||
).rejects.toThrow(/valid http/i);
|
||
});
|
||
|
||
// The bug: an extraction run opened with a seeded prompt that said "use the
|
||
// `brand-extract` skill", which the agent ran as a `Skill {"skill":
|
||
// "brand-extract"}` tool call that always fails (the daemon does not expose
|
||
// `skills/` as Claude-Code slash skills). These tests pin every seeded prompt
|
||
// the agent can actually receive so the inducement can never come back.
|
||
it('website ENRICHMENT prompt never tells the agent to invoke a brand-extract skill', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
// No userDesignSystemsRoot → no programmatic pass → the enrichment prompt is
|
||
// seeded directly (matches the "reserves the brand" baseline test).
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
const prompt = getProject(db, result.projectId)?.pendingPrompt ?? '';
|
||
expect(prompt).toContain('DESIGN SYSTEM ENRICHMENT');
|
||
// Workflow stays inline and the real measurement tool is still named.
|
||
expect(prompt).toContain('agent-browser');
|
||
expectNoPhantomSkillCall(prompt);
|
||
// And it positively steers the agent away from the phantom call.
|
||
expect(prompt).toContain('Do NOT try to load or invoke a `brand-extract` skill');
|
||
});
|
||
|
||
it('website EXTRACTION fallback prompt never tells the agent to invoke a brand-extract skill', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
// userDesignSystemsRoot + a fully blocked origin → the programmatic pass
|
||
// bails and the agent drives from the scaffold on the fallback prompt.
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'blocked.example',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: async () => null,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
const prompt = getProject(db, result.projectId)?.pendingPrompt ?? '';
|
||
expect(prompt).toContain('DESIGN SYSTEM EXTRACTION');
|
||
expect(prompt).toContain('agent-browser');
|
||
expectNoPhantomSkillCall(prompt);
|
||
expect(prompt).toContain('Do NOT try to load or invoke a `brand-extract` skill');
|
||
});
|
||
|
||
it('DESIGN.md enrichment prompt never tells the agent to invoke a brand-extract skill', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
// Pasted DESIGN.md (no website) → the programmatic parser registers a system
|
||
// and seeds the DESIGN.md enrichment prompt. This branch never used the skill
|
||
// phrasing, but the guard keeps it that way as the prompts evolve.
|
||
const result = await startOfflineBrandExtraction({
|
||
designMd: DESIGN_MD_INPUT,
|
||
description: 'A custom newsroom system for sharp editorial tools.',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: async () => {
|
||
throw new Error('website prefetch should not run for DESIGN.md-only input');
|
||
},
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
const prompt = getProject(db, result.projectId)?.pendingPrompt ?? '';
|
||
expect(prompt).toContain('context/input-DESIGN.md');
|
||
expectNoPhantomSkillCall(prompt);
|
||
expect(prompt).toContain('`brand.json.seed`');
|
||
expect(prompt).toContain('Do not edit `system/seed.json`');
|
||
});
|
||
|
||
it('renderBrandPreviewIntoProject re-renders brand.html from a partial brand.json', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
// Agent writes a partial kit (name + a couple colors, no fonts yet).
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({
|
||
name: 'Acme',
|
||
sourceUrl: started.sourceUrl,
|
||
colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[5]],
|
||
}),
|
||
'utf8',
|
||
);
|
||
|
||
const preview = await renderBrandPreviewIntoProject({
|
||
id: started.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
});
|
||
expect(preview.rendered).toBe(true);
|
||
expect(preview.file).toBe('brand.html');
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
// The partial palette flowed into the embedded payload, still "extracting".
|
||
expect(html).toContain('"status":"extracting"');
|
||
expect(html).toContain('#d97757');
|
||
expect(html).toContain('data-od-id="brand-name"');
|
||
expect(html).toContain('data-od-id="brand-color-hex-');
|
||
expect(html).toContain('data-od-id="brand-palette"');
|
||
expect(html).toContain("e.key === 'ArrowLeft'");
|
||
expect(html).toContain('showLight(lightIdx - 1)');
|
||
expect(html).toContain("e.key === 'ArrowRight'");
|
||
expect(html).toContain('showLight(lightIdx + 1)');
|
||
});
|
||
|
||
it('finalizeBrand registers the kit, marks it ready, and lights up the assets', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
patchMeta(brandsRoot, started.id, {
|
||
status: 'failed',
|
||
error: 'Old extraction failed.',
|
||
extractionTerminalRunId: 'run-old-failed',
|
||
extractionTerminalError: 'Old extraction failed.',
|
||
});
|
||
|
||
// Simulate the agent writing the complete kit into the backing project.
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({ ...VALID_BRAND, sourceUrl: started.sourceUrl }, null, 2),
|
||
'utf8',
|
||
);
|
||
writeFileSync(path.join(projectDir, 'BRAND.md'), '# Acme Brand Guide\n', 'utf8');
|
||
|
||
const finalized = await finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
expect(finalized.brand.name).toBe('Acme');
|
||
expect(finalized.designSystemId.startsWith('user:')).toBe(true);
|
||
expect(finalized.files.length).toBeGreaterThan(0);
|
||
|
||
const detail = readBrandDetail(brandsRoot, started.id);
|
||
expect(detail?.meta.status).toBe('ready');
|
||
expect(detail?.meta.error).toBeUndefined();
|
||
expect(detail?.meta.extractionTerminalRunId).toBeUndefined();
|
||
expect(detail?.meta.extractionTerminalError).toBeUndefined();
|
||
expect(detail?.meta.designSystemId).toBe(finalized.designSystemId);
|
||
|
||
const project = getProject(db, started.projectId);
|
||
expect(project?.designSystemId).toBe(finalized.designSystemId);
|
||
|
||
// brand.html re-rendered as ready, and the six artifacts exist so the
|
||
// Brand Assets tiles resolve.
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"ready"');
|
||
expect(existsSync(path.join(projectDir, 'system', 'artifacts', 'landing.html'))).toBe(true);
|
||
// The design-system module is wired up: the kit iframe + derived tokens.
|
||
expect(html).toContain('system/kit.html');
|
||
expect(existsSync(path.join(projectDir, 'system', 'kit.html'))).toBe(true);
|
||
expect(html).toMatch(/"colorPrimary":"#/);
|
||
});
|
||
|
||
it('finalizeBrand preserves authored seed overrides in the registered system', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify(
|
||
{
|
||
...VALID_BRAND,
|
||
sourceUrl: started.sourceUrl,
|
||
seed: { controlHeight: 44 },
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
await finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
const persistedBrand = JSON.parse(
|
||
readFileSync(path.join(projectDir, 'brand.json'), 'utf8'),
|
||
) as { seed?: { controlHeight?: number } };
|
||
expect(persistedBrand.seed?.controlHeight).toBe(44);
|
||
|
||
const generatedSeed = JSON.parse(
|
||
readFileSync(path.join(projectDir, 'system', 'seed.json'), 'utf8'),
|
||
) as { controlHeight?: number };
|
||
expect(generatedSeed.controlHeight).toBe(44);
|
||
|
||
const generatedGuide = readFileSync(
|
||
path.join(projectDir, 'system', 'BRAND-SYSTEM.md'),
|
||
'utf8',
|
||
);
|
||
expect(generatedGuide).toContain('`brand.json.seed`');
|
||
expect(generatedGuide).not.toContain('only authored surface');
|
||
});
|
||
|
||
it('finalizeBrand is idempotent — re-finalizing reuses the brand design system', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({ ...VALID_BRAND, sourceUrl: started.sourceUrl }, null, 2),
|
||
'utf8',
|
||
);
|
||
|
||
const finalizeOnce = () =>
|
||
finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
// The live extraction agent may re-run `od brand finalize` (e.g. after
|
||
// fixing a validation error or enriching the kit). A second finalize must
|
||
// reuse the brand's existing design system, not register a duplicate.
|
||
const first = await finalizeOnce();
|
||
const second = await finalizeOnce();
|
||
|
||
expect(second.designSystemId).toBe(first.designSystemId);
|
||
|
||
// Exactly one `user:<id>` design system exists for the brand, so it never
|
||
// shows up twice in any design-system picker.
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems.filter((s) => s.title === 'Acme')).toHaveLength(1);
|
||
|
||
const detail = readBrandDetail(brandsRoot, started.id);
|
||
expect(detail?.meta.designSystemId).toBe(first.designSystemId);
|
||
});
|
||
|
||
it('finalizeBrand fails clearly when the agent has not written brand.json yet', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
await expect(
|
||
finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
}),
|
||
).rejects.toThrow(/brand\.json not found/i);
|
||
});
|
||
|
||
it('preview renders the imagery gallery + font tiles from imagery.samples', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({
|
||
name: 'Acme',
|
||
sourceUrl: started.sourceUrl,
|
||
colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]],
|
||
typography: VALID_BRAND.typography,
|
||
imagery: {
|
||
style: 'bold gradients',
|
||
samples: [
|
||
{ file: 'imagery/hero.png', kind: 'hero', caption: 'Homepage hero' },
|
||
{ file: 'imagery/product.webp', kind: 'product', caption: 'Product screenshot' },
|
||
],
|
||
},
|
||
}),
|
||
'utf8',
|
||
);
|
||
|
||
const preview = await renderBrandPreviewIntoProject({
|
||
id: started.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
});
|
||
expect(preview.rendered).toBe(true);
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
// The harvested image paths flow into the embedded payload so the gallery
|
||
// <img src> resolve under the FileViewer raw route.
|
||
expect(html).toContain('imagery/hero.png');
|
||
expect(html).toContain('imagery/product.webp');
|
||
// The kit template ships the gallery + font-specimen-tile renderers.
|
||
expect(html).toContain('<div class="gallery">');
|
||
expect(html).toContain('<div class="fonts" data-od-id="brand-fonts"');
|
||
});
|
||
|
||
it('preview falls back to a logo alternate when logo.primary is empty', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({
|
||
name: 'Acme',
|
||
sourceUrl: started.sourceUrl,
|
||
colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]],
|
||
logo: { primary: null, alternates: ['logos/favicon.ico'], notes: '' },
|
||
}),
|
||
'utf8',
|
||
);
|
||
|
||
await renderBrandPreviewIntoProject({
|
||
id: started.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
});
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
// The alternate path is embedded so the page can render it instead of the
|
||
// "No logo found" empty state.
|
||
expect(html).toContain('logos/favicon.ico');
|
||
});
|
||
|
||
it('startBrandExtraction seeds the deterministic logo fallback into the page', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
// Stub harvester: writes a mark into logos/ and populates logo.primary,
|
||
// exactly as the real network fallback would, but offline.
|
||
const stubFallback = async (
|
||
_url: string,
|
||
logosDir: string,
|
||
logo: { primary: string | null; alternates: string[]; notes: string },
|
||
) => {
|
||
mkdirSync(logosDir, { recursive: true });
|
||
writeFileSync(path.join(logosDir, 'apple-touch-icon.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||
logo.primary = 'logos/apple-touch-icon.png';
|
||
logo.notes = 'Auto-fetched site icon.';
|
||
return { changed: true };
|
||
};
|
||
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: stubFallback,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
expect(existsSync(path.join(projectDir, 'logos', 'apple-touch-icon.png'))).toBe(true);
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
// The seeded page already carries a real mark, not "No logo found".
|
||
expect(html).toContain('logos/apple-touch-icon.png');
|
||
});
|
||
|
||
it('finalizeBrand adopts on-disk logo files when the agent left logo.primary empty', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
// The agent (or the seed harvester) saved real logo files into the project,
|
||
// but the agent's brand.json left `logo.primary` empty. The finalize logo
|
||
// safety net must wire the existing files into logo.primary so the kit page
|
||
// never reports "No logo found" while real marks sit in logos/.
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
mkdirSync(path.join(projectDir, 'logos'), { recursive: true });
|
||
writeFileSync(
|
||
path.join(projectDir, 'logos', 'header.svg'),
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40"><rect width="120" height="40" /></svg>',
|
||
'utf8',
|
||
);
|
||
writeFileSync(
|
||
path.join(projectDir, 'logos', 'apple-touch-icon.png'),
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify(
|
||
{ ...VALID_BRAND, sourceUrl: started.sourceUrl, logo: { primary: null, alternates: [], notes: '' } },
|
||
null,
|
||
2,
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
// Use the REAL fallback: with files already on disk it must adopt them
|
||
// offline (no network), not bail out.
|
||
const finalized = await finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: ensureLogoFallback,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
expect(finalized.brand.logo.primary).toBe('logos/header.svg');
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"ready"');
|
||
expect(html).toContain('logos/header.svg');
|
||
// The adopted primary is embedded in the page payload, so the kit renders a
|
||
// real mark instead of the empty "No logo found" state.
|
||
expect(html).toContain('"primary":"logos/header.svg"');
|
||
|
||
// The synced project brand.json carries the adopted primary too, so the
|
||
// Brands tab and the opened project render an identical, complete logo.
|
||
const projectBrandJson = JSON.parse(
|
||
readFileSync(path.join(projectDir, 'brand.json'), 'utf8'),
|
||
) as { logo?: { primary?: string | null } };
|
||
expect(projectBrandJson.logo?.primary).toBe('logos/header.svg');
|
||
});
|
||
|
||
it('preview adopts on-disk project logos so the live page is never logo-less', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
// Seed harvester wrote a mark into logos/, but the agent then overwrote
|
||
// brand.json with an empty logo while still measuring the rest.
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
mkdirSync(path.join(projectDir, 'logos'), { recursive: true });
|
||
writeFileSync(
|
||
path.join(projectDir, 'logos', 'apple-touch-icon.png'),
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
);
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({
|
||
name: 'Acme',
|
||
sourceUrl: started.sourceUrl,
|
||
colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]],
|
||
logo: { primary: null, alternates: [], notes: '' },
|
||
}),
|
||
'utf8',
|
||
);
|
||
|
||
await renderBrandPreviewIntoProject({
|
||
id: started.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
});
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('logos/apple-touch-icon.png');
|
||
// Embedded as the payload primary, so the live page shows the seed mark.
|
||
expect(html).toContain('"primary":"logos/apple-touch-icon.png"');
|
||
});
|
||
|
||
it('finalizeBrand mirrors imagery/ and renders the gallery on the ready page', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
// Agent saved a real image + referenced it from imagery.samples.
|
||
mkdirSync(path.join(projectDir, 'imagery'), { recursive: true });
|
||
writeFileSync(path.join(projectDir, 'imagery', 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify(
|
||
{
|
||
...VALID_BRAND,
|
||
sourceUrl: started.sourceUrl,
|
||
imagery: {
|
||
style: 'bold gradients',
|
||
subjects: ['product'],
|
||
treatment: 'high contrast',
|
||
avoid: [],
|
||
samples: [{ file: 'imagery/hero.png', kind: 'hero', caption: 'Homepage hero' }],
|
||
},
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
const finalized = await finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
// Samples survive validation and the image is mirrored into the brand dir
|
||
// and synced back to the project so the gallery still resolves.
|
||
expect(finalized.brand.imagery.samples?.[0]?.file).toBe('imagery/hero.png');
|
||
expect(existsSync(path.join(brandsRoot, started.id, 'imagery', 'hero.png'))).toBe(true);
|
||
expect(existsSync(path.join(projectDir, 'imagery', 'hero.png'))).toBe(true);
|
||
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"ready"');
|
||
expect(html).toContain('imagery/hero.png');
|
||
});
|
||
|
||
it('findImageRefs harvests large cover/hero images and drops icons/logos', () => {
|
||
const html = [
|
||
'<html><head>',
|
||
'<meta property="og:image" content="https://cdn.site.com/cover.jpg">',
|
||
'<link rel="preload" as="image" href="/img/hero.png">',
|
||
'</head><body>',
|
||
'<picture><source srcset="/img/big-800.webp 800w, /img/big-400.webp 400w"></picture>',
|
||
'<img src="/img/photo.jpg" width="900" height="600">',
|
||
'<img src="/icons/favicon.png" width="32" height="32">',
|
||
'<img src="/img/logo.svg">',
|
||
'<div style="background-image:url(\'/img/bg-hero.jpg\')"></div>',
|
||
'</body></html>',
|
||
].join('\n');
|
||
|
||
const refs = findImageRefs(html, 'https://site.com/');
|
||
const urls = refs.map((r) => r.url);
|
||
|
||
// og:image is the strongest representative candidate.
|
||
const cover = refs.find((r) => r.url.endsWith('/cover.jpg'));
|
||
expect(cover?.rank).toBe(0);
|
||
expect(cover?.kind).toBe('cover');
|
||
|
||
// Hero preload, the largest srcset source, the big <img>, and the CSS hero
|
||
// background all survive.
|
||
expect(urls).toContain('https://site.com/img/hero.png');
|
||
expect(urls).toContain('https://site.com/img/big-800.webp');
|
||
expect(urls).not.toContain('https://site.com/img/big-400.webp');
|
||
expect(urls).toContain('https://site.com/img/photo.jpg');
|
||
expect(urls).toContain('https://site.com/img/bg-hero.jpg');
|
||
|
||
// Chrome is dropped: a 32px favicon, and an SVG logo.
|
||
expect(urls.some((u) => u.includes('favicon'))).toBe(false);
|
||
expect(urls.some((u) => u.includes('logo.svg'))).toBe(false);
|
||
});
|
||
|
||
it('imageSize decodes PNG and GIF header dimensions', () => {
|
||
expect(imageSize(pngBuffer(1200, 630))).toEqual({ w: 1200, h: 630 });
|
||
|
||
const gif = Buffer.alloc(24);
|
||
gif.write('GIF89a', 0, 'ascii');
|
||
gif.writeUInt16LE(640, 6);
|
||
gif.writeUInt16LE(480, 8);
|
||
expect(imageSize(gif)).toEqual({ w: 640, h: 480 });
|
||
|
||
// A buffer too short to carry a header decodes to null (size gate rejects).
|
||
expect(imageSize(Buffer.from([0x00, 0x01, 0x02]))).toBeNull();
|
||
});
|
||
|
||
it('adoptExistingImagery wires on-disk images into imagery.samples offline', () => {
|
||
const imageryDir = path.join(tempDir, 'adopt-imagery');
|
||
mkdirSync(imageryDir, { recursive: true });
|
||
writeFileSync(path.join(imageryDir, 'hero.png'), pngBuffer(1600, 900));
|
||
writeFileSync(path.join(imageryDir, 'screenshot.png'), pngBuffer(1280, 720));
|
||
|
||
const imagery: ImagerySlot = { samples: [] };
|
||
const result = adoptExistingImagery(imageryDir, imagery);
|
||
|
||
expect(result.changed).toBe(true);
|
||
const files = (imagery.samples ?? []).map((s) => s.file).sort();
|
||
expect(files).toEqual(['imagery/hero.png', 'imagery/screenshot.png']);
|
||
});
|
||
|
||
it('finalizeBrand runs the imagery fallback when the agent saved no samples', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
const started = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
});
|
||
|
||
const projectDir = path.join(projectsRoot, started.projectId);
|
||
// Agent wrote a complete kit but captured ZERO imagery samples — exactly the
|
||
// case the displayed pre-imagery brand was stuck in.
|
||
writeFileSync(
|
||
path.join(projectDir, 'brand.json'),
|
||
JSON.stringify({ ...VALID_BRAND, sourceUrl: started.sourceUrl }, null, 2),
|
||
'utf8',
|
||
);
|
||
|
||
// Stub harvester mirrors the real one offline: it saves cover/hero images
|
||
// into imagery/ and records them in imagery.samples.
|
||
const stubImageryFallback = async (
|
||
_url: string,
|
||
imageryDir: string,
|
||
imagery: ImagerySlot,
|
||
) => {
|
||
mkdirSync(imageryDir, { recursive: true });
|
||
writeFileSync(path.join(imageryDir, 'cover-0.jpg'), pngBuffer(1200, 630));
|
||
writeFileSync(path.join(imageryDir, 'hero-1.png'), pngBuffer(1600, 900));
|
||
writeFileSync(path.join(imageryDir, 'hero-2.png'), pngBuffer(1440, 810));
|
||
imagery.samples = [
|
||
{ file: 'imagery/cover-0.jpg', kind: 'cover', caption: 'Social cover image' },
|
||
{ file: 'imagery/hero-1.png', kind: 'hero', caption: 'Hero image' },
|
||
{ file: 'imagery/hero-2.png', kind: 'hero', caption: 'Hero image 2' },
|
||
];
|
||
return { changed: true };
|
||
};
|
||
|
||
const finalized = await finalizeBrand({
|
||
id: started.id,
|
||
brandsRoot,
|
||
userDesignSystemsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: stubImageryFallback,
|
||
});
|
||
|
||
// The harvested samples flow into the validated brand and the saved files
|
||
// land in BOTH the brand dir and the synced project dir.
|
||
expect(finalized.brand.imagery.samples?.length).toBe(3);
|
||
expect(finalized.brand.imagery.samples?.[0]?.file).toBe('imagery/cover-0.jpg');
|
||
expect(existsSync(path.join(brandsRoot, started.id, 'imagery', 'cover-0.jpg'))).toBe(true);
|
||
expect(existsSync(path.join(projectDir, 'imagery', 'hero-1.png'))).toBe(true);
|
||
|
||
// The synced project brand.json carries the samples so the Brands tab
|
||
// gallery resolves identically to the kit page.
|
||
const projectBrandJson = JSON.parse(
|
||
readFileSync(path.join(projectDir, 'brand.json'), 'utf8'),
|
||
) as { imagery?: { samples?: Array<{ file: string }> } };
|
||
expect(projectBrandJson.imagery?.samples?.map((s) => s.file)).toContain('imagery/cover-0.jpg');
|
||
|
||
// The ready kit page renders the Images gallery with the harvested files.
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"ready"');
|
||
expect(html).toContain('<div class="gallery">');
|
||
expect(html).toContain('imagery/cover-0.jpg');
|
||
});
|
||
|
||
it('startBrandExtraction starts programmatic design-system extraction in the background', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
// Stub the network harvest: write a real logo into the brand dir and return
|
||
// measured material, exactly as the live prefetch would, but offline.
|
||
const stubPrefetch = async (_url: string, brandDir: string) => {
|
||
const logosDir = path.join(brandDir, 'logos');
|
||
mkdirSync(logosDir, { recursive: true });
|
||
writeFileSync(
|
||
path.join(logosDir, 'header.svg'),
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40"><rect width="120" height="40" /></svg>',
|
||
'utf8',
|
||
);
|
||
return {
|
||
url: 'https://acme.com/',
|
||
finalUrl: 'https://acme.com/',
|
||
siteName: 'Acme',
|
||
title: 'Acme — we make things',
|
||
description: 'Acme makes excellent things for everyone.',
|
||
colors: [
|
||
{ hex: '#ffffff', count: 50 },
|
||
{ hex: '#1a1a18', count: 30 },
|
||
{ hex: '#d97757', count: 18 },
|
||
],
|
||
fonts: [{ family: 'Inter', count: 22 }],
|
||
fontFaceFamilies: [],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [
|
||
{ file: 'header.svg', sourceUrl: 'https://acme.com/', kind: 'inline-svg' as const, bytes: 120 },
|
||
],
|
||
headings: ['We make things'],
|
||
paragraphs: ['Acme makes excellent things for everyone.'],
|
||
navLabels: [],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
thin: false,
|
||
blocked: false,
|
||
materialMd: '',
|
||
};
|
||
};
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
// Switch on the programmatic-first path; keep the safety-net fallbacks
|
||
// offline so the test never touches the network.
|
||
userDesignSystemsRoot,
|
||
prefetch: stubPrefetch,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
|
||
// The project and transcript are available immediately while the
|
||
// deterministic harvest continues in the background.
|
||
expect(result.status).toBe('extracting');
|
||
expect(result.designSystemId).toMatch(/^user:/);
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
const initialMessages = listMessages(db, result.conversationId);
|
||
expect(initialMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(initialMessages[0]?.content).toContain('https://acme.com/');
|
||
expect(initialMessages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
expect(initialMessages[1]?.runStatus).toBe('running');
|
||
|
||
await backgroundExtraction;
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const messages = listMessages(db, result.conversationId);
|
||
if (messages.some((message) => message.content.includes('Programmatic extraction finished'))) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
|
||
// The background pass registers and syncs the usable design system — no
|
||
// agent run required.
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('ready');
|
||
expect(detail?.meta.designSystemId?.startsWith('user:')).toBe(true);
|
||
expect(detail?.brand?.name).toBe('Acme');
|
||
expect(detail?.brand?.tagline).toBe('We make things');
|
||
expect(detail?.brand?.logo.primary).toBe('logos/header.svg');
|
||
const completedMessages = listMessages(db, result.conversationId);
|
||
const completedAssistant = completedMessages.find((message) =>
|
||
message.content.includes('Programmatic extraction finished'),
|
||
);
|
||
expect(completedAssistant?.runStatus).toBe('succeeded');
|
||
|
||
// The backing project's design system page renders ready, with the six
|
||
// artifacts built, so it is applyable once the background pass settles.
|
||
const projectDir = path.join(projectsRoot, result.projectId);
|
||
const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"ready"');
|
||
expect(existsSync(path.join(projectDir, 'system', 'artifacts', 'landing.html'))).toBe(true);
|
||
|
||
// Exactly one reusable design system was registered for the brand.
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(systems.filter((s) => s.title === 'Acme')).toHaveLength(1);
|
||
|
||
// The agent prompt is still seeded so the async AI enrichment pass can run.
|
||
const project = getProject(db, result.projectId);
|
||
expect(project?.pendingPrompt ?? '').toContain('DESIGN SYSTEM ENRICHMENT');
|
||
expect(project?.designSystemId).toBe(detail?.meta.designSystemId);
|
||
});
|
||
|
||
it('startBrandExtraction registers directly from a pasted DESIGN.md without a website', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
designMd: DESIGN_MD_INPUT,
|
||
description: 'A custom newsroom system for sharp editorial tools.',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: async () => {
|
||
throw new Error('website prefetch should not run for DESIGN.md-only input');
|
||
},
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
|
||
expect(result.status).toBe('extracting');
|
||
expect(result.designSystemId).toMatch(/^user:/);
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
const initialMessages = listMessages(db, result.conversationId);
|
||
expect(initialMessages.map((message) => message.role)).toEqual(['user', 'assistant']);
|
||
expect(initialMessages[0]?.content).toContain('pasted DESIGN.md');
|
||
expect(initialMessages[1]?.content).toContain('Programmatic design-system extraction started');
|
||
expect(initialMessages[1]?.runStatus).toBe('running');
|
||
|
||
await backgroundExtraction;
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const messages = listMessages(db, result.conversationId);
|
||
if (messages.some((message) => message.content.includes('Programmatic extraction finished'))) break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('ready');
|
||
expect(detail?.meta.sourceUrl).toBe('designmd://heritage');
|
||
expect(detail?.meta.designSystemId?.startsWith('user:')).toBe(true);
|
||
expect(detail?.brand?.name).toBe('Heritage');
|
||
expect(detail?.brand?.description).toContain('custom newsroom');
|
||
expect(detail?.brand?.colors.find((color) => color.role === 'accent')?.hex).toBe('#b8422e');
|
||
expect(detail?.brand?.typography.display.family).toBe('Public Sans');
|
||
const completedMessages = listMessages(db, result.conversationId);
|
||
const completedAssistant = completedMessages.find((message) =>
|
||
message.content.includes('Programmatic extraction finished'),
|
||
);
|
||
expect(completedAssistant?.runStatus).toBe('succeeded');
|
||
|
||
const projectDir = path.join(projectsRoot, result.projectId);
|
||
expect(readFileSync(path.join(projectDir, 'context', 'input-DESIGN.md'), 'utf8')).toContain('name: Heritage');
|
||
expect(existsSync(path.join(projectDir, 'system', 'scripts', 'apply-design-tokens.mjs'))).toBe(true);
|
||
const project = getProject(db, result.projectId);
|
||
expect(project?.pendingPrompt ?? '').toContain('context/input-DESIGN.md');
|
||
expect(project?.designSystemId).toBe(detail?.meta.designSystemId);
|
||
const tabs = listTabs(db, result.projectId);
|
||
expect('browserTabs' in tabs ? tabs.browserTabs ?? [] : []).toHaveLength(0);
|
||
});
|
||
|
||
it('startBrandExtraction renders a failed preview when the programmatic harvest fails', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
|
||
// Prefetch returns null (fully blocked / unreachable origin) → no design
|
||
// system is built; once the background pass settles, the draft becomes
|
||
// editable and the agent/browser fallback can continue from it.
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: async () => null,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('failed');
|
||
// Entity-first: the draft design system exists, but was not finalized.
|
||
expect(detail?.meta.designSystemId).toMatch(/^user:/);
|
||
const draftSystems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
expect(draftSystems.find((s) => s.id === detail?.meta.designSystemId)?.status).toBe('draft');
|
||
|
||
const html = readFileSync(path.join(projectsRoot, result.projectId, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"failed"');
|
||
expect(html).toContain('"extractionFailed":"Extraction failed"');
|
||
});
|
||
|
||
it('renders stopped programmatic extraction previews as a saved draft', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
patchMeta(brandsRoot, result.id, { status: 'failed', error: 'Stopped by user' });
|
||
await renderBrandPreviewIntoProject({
|
||
id: result.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
projectId: result.projectId,
|
||
previewStatus: 'draft',
|
||
});
|
||
|
||
const html = readFileSync(path.join(projectsRoot, result.projectId, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"draft"');
|
||
expect(html).toContain('"draftSaved":"Draft saved"');
|
||
expect(html).not.toContain('"status":"extracting"');
|
||
});
|
||
|
||
it('renders genuinely failed extraction previews as failed', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'acme.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
});
|
||
|
||
patchMeta(brandsRoot, result.id, {
|
||
status: 'failed',
|
||
error: 'Daemon extraction failed',
|
||
});
|
||
await renderBrandPreviewIntoProject({
|
||
id: result.id,
|
||
brandsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
projectsRoot,
|
||
projectId: result.projectId,
|
||
});
|
||
|
||
const html = readFileSync(path.join(projectsRoot, result.projectId, 'brand.html'), 'utf8');
|
||
expect(html).toContain('"status":"failed"');
|
||
expect(html).toContain('"extractionFailed":"Extraction failed"');
|
||
});
|
||
|
||
it('does not finalize blocked or thin programmatic harvests as ready', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
for (const [host, flags, expectedStatus, expectedKitStatus] of [
|
||
// A blocked origin is recoverable via the in-app Browser tab + Continue, so
|
||
// it parks in the calm `needs_input` state (kit shows the in-progress
|
||
// "extracting" view) rather than the red `failed` terminal.
|
||
['blocked.example', { blocked: true, thin: true }, 'needs_input', 'extracting'],
|
||
// A genuinely thin, non-blocked harvest is unrecoverable and stays `failed`.
|
||
['thin.example', { blocked: false, thin: true }, 'failed', 'failed'],
|
||
] as const) {
|
||
let backgroundExtraction: Promise<unknown> | null = null;
|
||
const result = await startOfflineBrandExtraction({
|
||
url: host,
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: async () => ({
|
||
url: `https://${host}/`,
|
||
finalUrl: `https://${host}/`,
|
||
siteName: 'Fallback',
|
||
title: '',
|
||
description: '',
|
||
colors: [
|
||
{ hex: '#ffffff', count: 50, extreme: true },
|
||
{ hex: '#111111', count: 30, extreme: true },
|
||
],
|
||
fonts: [],
|
||
fontFaceFamilies: [],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [],
|
||
headings: [],
|
||
paragraphs: [],
|
||
navLabels: [],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
materialMd: '',
|
||
...flags,
|
||
}),
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
backgroundExtraction = settled;
|
||
},
|
||
});
|
||
if (!backgroundExtraction) throw new Error('expected background extraction promise');
|
||
await backgroundExtraction;
|
||
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe(expectedStatus);
|
||
// Entity-first: the draft exists after the give-up but is not promoted.
|
||
expect(detail?.meta.designSystemId).toMatch(/^user:/);
|
||
const html = readFileSync(path.join(projectsRoot, result.projectId, 'brand.html'), 'utf8');
|
||
expect(html).toContain(`"status":"${expectedKitStatus}"`);
|
||
|
||
const project = getProject(db, result.projectId);
|
||
expect(project?.pendingPrompt ?? '').toContain('DESIGN SYSTEM EXTRACTION');
|
||
expect(project?.pendingPrompt ?? '').toContain('ready design system is NOT guaranteed yet');
|
||
expect(project?.pendingPrompt ?? '').not.toContain('DESIGN SYSTEM ENRICHMENT');
|
||
expect(project?.pendingPrompt ?? '').not.toContain('ALREADY been extracted programmatically');
|
||
}
|
||
|
||
const systems = await listDesignSystems(userDesignSystemsRoot, {
|
||
idPrefix: 'user:',
|
||
source: 'user',
|
||
isEditable: true,
|
||
defaultStatus: 'draft',
|
||
});
|
||
// Entity-first: each blocked/thin extraction leaves a draft behind (editable),
|
||
// but NONE were finalized to published.
|
||
expect(systems).toHaveLength(2);
|
||
expect(systems.every((s) => s.status === 'draft')).toBe(true);
|
||
});
|
||
|
||
it('classifies EO_Bot_Ssid verification pages as anti-bot challenges', () => {
|
||
expect(isChallengePage(`
|
||
<!doctype html>
|
||
<html>
|
||
<head><title>旺旺集团</title></head>
|
||
<body>
|
||
<script>
|
||
document.cookie = "EO_Bot_Ssid=abc123";
|
||
window.__tst_status = "verify";
|
||
location.reload();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`)).toBe(true);
|
||
});
|
||
|
||
it('returns fast without blocking on a slow programmatic harvest, then finalizes in the background', async () => {
|
||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||
|
||
// A slow origin: the harvest takes far longer than the start response should
|
||
// ever wait. The user must still land in the project promptly.
|
||
const SLOW_MS = 1_500;
|
||
const stubPrefetch = async (_url: string, brandDir: string) => {
|
||
await new Promise((resolve) => setTimeout(resolve, SLOW_MS));
|
||
const logosDir = path.join(brandDir, 'logos');
|
||
mkdirSync(logosDir, { recursive: true });
|
||
writeFileSync(
|
||
path.join(logosDir, 'header.svg'),
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40"><rect width="120" height="40" /></svg>',
|
||
'utf8',
|
||
);
|
||
return {
|
||
url: 'https://slow.com/',
|
||
finalUrl: 'https://slow.com/',
|
||
siteName: 'Slow',
|
||
title: 'Slow — eventually',
|
||
description: 'Slow makes things, eventually.',
|
||
colors: [
|
||
{ hex: '#ffffff', count: 50 },
|
||
{ hex: '#1a1a18', count: 30 },
|
||
{ hex: '#d97757', count: 18 },
|
||
],
|
||
fonts: [{ family: 'Inter', count: 22 }],
|
||
fontFaceFamilies: [],
|
||
googleFontsUrls: [],
|
||
fontFiles: [],
|
||
logos: [
|
||
{ file: 'header.svg', sourceUrl: 'https://slow.com/', kind: 'inline-svg' as const, bytes: 120 },
|
||
],
|
||
headings: ['Eventually'],
|
||
paragraphs: ['Slow makes things, eventually.'],
|
||
navLabels: [],
|
||
extraPages: [],
|
||
screenshot: null,
|
||
thin: false,
|
||
blocked: false,
|
||
materialMd: '',
|
||
};
|
||
};
|
||
|
||
let background: Promise<unknown> | null = null;
|
||
const startedAt = Date.now();
|
||
const result = await startOfflineBrandExtraction({
|
||
url: 'slow.com',
|
||
brandsRoot,
|
||
projectsRoot,
|
||
skillsRoot: SKILLS_ROOT,
|
||
db,
|
||
userDesignSystemsRoot,
|
||
prefetch: stubPrefetch,
|
||
logoFallback: NO_LOGO_FALLBACK,
|
||
imageryFallback: NO_IMAGERY_FALLBACK,
|
||
onBackgroundExtraction: (settled) => {
|
||
background = settled;
|
||
},
|
||
});
|
||
const elapsed = Date.now() - startedAt;
|
||
|
||
// The start response returned long before the slow harvest could finish.
|
||
expect(elapsed).toBeLessThan(SLOW_MS);
|
||
expect(background).not.toBeNull();
|
||
|
||
// At return time the brand is still extracting (skeleton page), so the user
|
||
// sees a progress state rather than waiting on the network.
|
||
expect(result.status).toBe('extracting');
|
||
expect(result.designSystemId).toMatch(/^user:/);
|
||
expect(readBrandDetail(brandsRoot, result.id)?.meta.status).toBe('extracting');
|
||
|
||
// Once the background harvest settles, the brand finalizes to ready.
|
||
await background;
|
||
const detail = readBrandDetail(brandsRoot, result.id);
|
||
expect(detail?.meta.status).toBe('ready');
|
||
expect(detail?.meta.designSystemId?.startsWith('user:')).toBe(true);
|
||
});
|
||
});
|