sitesmith: site_pages in requests, per-page build phases, 10 minute cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 17:21:14 -08:00
1 parent 28119f0666
commit 201486f357
6 files changed
+127 -6

No files matched your search

+37 -3
View File
@@ -153,7 +153,7 @@ describe('useSitesmith start+poll', () => {
// 60s at <=5s spacing -> well under the 60 a fixed 1s interval would give
expect(polls).toBeLessThan(25);
expect(polls).toBeGreaterThan(10);
await act(async () => { await vi.advanceTimersByTimeAsync(400000); });
await act(async () => { await vi.advanceTimersByTimeAsync(600000); });
await p;
});
@@ -210,14 +210,24 @@ describe('useSitesmith start+poll', () => {
expect(res.message).toMatch(/reopen Sitesmith/);
});
test('overall 6 minute cap -> TIMEOUT (job may still finish)', async () => {
test('overall 10 minute cap (per-page whole-site builds) -> TIMEOUT (job may still finish)', async () => {
script(OKSTART, [running()]);
const res = await sendFor(7 * 60 * 1000, 'hi', 'c');
const res = await sendFor(11 * 60 * 1000, 'hi', 'c');
expect(res).toMatchObject({ ok: false, status: 'TIMEOUT', jobId: JOB });
expect(res.message).toMatch(/background/);
expect(res.message).toMatch(/history/);
});
test('a job still running after 8 minutes is still being waited for (cap is 10, not 6)', async () => {
script(OKSTART, [running('page:3/5')]);
vi.useFakeTimers();
let p!: Promise<any>; let settled = false;
await act(async () => { p = api.send('hi', 'c'); p.then(() => { settled = true; }); await vi.advanceTimersByTimeAsync(8 * 60 * 1000); });
expect(settled).toBe(false);
await act(async () => { await vi.advanceTimersByTimeAsync(3 * 60 * 1000); });
expect(await p).toMatchObject({ ok: false, status: 'TIMEOUT' });
});
test('abort via signal stops polling and resolves ABORTED', async () => {
script(OKSTART, [running()]);
const ac = new AbortController();
@@ -430,6 +440,30 @@ describe('request payload: design, assets and attachments', () => {
});
});
test('site_pages rides on a normal send and a regenerate, never on a review', async () => {
const bodies: any[] = [];
override = (url, init) => {
if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j7', status: 'running', phase: 'queued' }) }; }
if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'done', phase: 'done', result: { ok: true, response: { type: 'ask', question: 'q' } } }) };
return undefined;
};
const sitePages = [{ title: 'Home', path: '/' }, { title: 'About', path: '/about' }];
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
try {
await act(async () => {
for (const o of [{ sitePages }, { sitePages, intent: 'regenerate_section' as const }, { sitePages, intent: 'review' as const, reviewImageIds: ['b'.repeat(32)] }, {}]) {
const p = api.send('hi', 'canvas', undefined, o);
await vi.advanceTimersByTimeAsync(1200);
await p;
}
});
} finally { vi.useRealTimers(); }
expect(bodies[0].site_pages).toEqual(sitePages);
expect(bodies[1].site_pages).toEqual(sitePages);
expect(bodies[2].site_pages).toBeUndefined();
expect(bodies[3].site_pages).toBeUndefined();
});
test('self-review: intent review + review_image_ids (the render is never sent as a mockup); typed failure statuses', async () => {
const bodies: any[] = [];
override = (url, init) => {
+5 -1
View File
@@ -70,6 +70,8 @@ export interface SitesmithSendOpts {
assetFilenames?: string[];
/** The shared header/footer are still the editor's untouched starter ones (sent as chrome_default). */
chromeDefault?: boolean;
/** The site's existing pages (utils/site-pages.ts), sent as site_pages on every non-review request. */
sitePages?: { title: string; path: string }[];
onProgress?: (phase: string) => void;
signal?: AbortSignal;
}
@@ -82,7 +84,8 @@ export type SitesmithHookStatus = SitesmithClientStatus;
const POLL_START_MS = 1000;
const POLL_GROWTH = 1.4;
const POLL_CAP_MS = 5000;
const OVERALL_CAP_MS = 6 * 60 * 1000;
// A whole-site build runs one model call per page server-side.
const OVERALL_CAP_MS = 10 * 60 * 1000;
const MAX_POLL_FAILURES = 5;
export const MSG_NETWORK = 'Could not reach the server. Check your connection and try again.';
@@ -365,6 +368,7 @@ export function useSitesmith(siteId: number) {
if (opts?.intent === 'review' && opts.reviewImageIds?.length) startBody.review_image_ids = opts.reviewImageIds;
if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames;
if (opts?.chromeDefault && opts?.intent !== 'review') startBody.chrome_default = true;
if (opts?.sitePages?.length && opts?.intent !== 'review') startBody.site_pages = opts.sitePages.slice(0, 30);
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
method: 'POST',
credentials: 'include',
+19 -2
View File
@@ -10,8 +10,25 @@ export const PHASE_LABEL: Record<string, string> = {
finalizing: 'Finishing up',
done: 'Done',
review: 'Reviewing the finished page',
// Whole-site builds run one model call per page on the server.
planning: 'Planning your pages',
assembling: 'Putting your site together',
};
const PAGE_PHASE = /^page:(\d)\/(\d)$/;
/** Plain-words label for a phase; `page:K/N` is built from the numbers, anything unknown is "Working". */
export function phaseLabel(phase: string): string {
const m = PAGE_PHASE.exec(phase);
if (m) return `Building page ${m[1]} of ${m[2]}`;
return PHASE_LABEL[phase] ?? 'Working';
}
/** The progress-bar stage a phase belongs to: the per-page build steps are all part of generating. */
function stageOf(phase: string): string {
return phase === 'planning' || phase === 'assembling' || PAGE_PHASE.test(phase) ? 'generating' : phase;
}
const ORDER = ['queued', 'preparing', 'captioning', 'generating', 'finalizing'];
interface Props {
@@ -31,8 +48,8 @@ export const JobStatus: React.FC<Props> = ({ phase, startedAgoS = 0, onCancel, c
const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 1000);
return () => window.clearInterval(id);
}, [t0]);
const label = cancelling ? 'Cancelling…' : (PHASE_LABEL[phase] ?? 'Working');
const idx = ORDER.indexOf(phase);
const label = cancelling ? 'Cancelling…' : phaseLabel(phase);
const idx = ORDER.indexOf(stageOf(phase));
return (
<div role="status" aria-live="polite" data-testid="ss-job-status" data-phase={phase} style={{ padding: '10px 4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 14 }}>
@@ -234,3 +234,28 @@ describe('JobStatus', () => {
expect((box.querySelector('[data-testid=ss-cancel]') as HTMLButtonElement).disabled).toBe(true);
});
});
describe('JobStatus: multi-page build phases', () => {
const label = () => box.querySelector('[data-testid=ss-phase-label]')!.textContent;
const bars = () => Array.from(box.querySelectorAll('[aria-hidden] > span')) as HTMLElement[];
test.each([
['planning', 'Planning your pages'],
['page:2/5', 'Building page 2 of 5'],
['assembling', 'Putting your site together'],
['something-new', 'Working'],
])('%s -> %s, data-phase keeps the raw phase', async (phase, text) => {
await render(<JobStatus phase={phase} />);
expect(label()).toContain(text);
expect(box.querySelector('[data-testid=ss-job-status]')!.getAttribute('data-phase')).toBe(phase);
});
test('page:K/N and planning/assembling sit in the generating stage of the progress bar', async () => {
await render(<JobStatus phase="generating" />);
const gen = bars().map((b) => b.style.background).join('|');
for (const p of ['planning', 'page:1/3', 'assembling']) {
await render(<JobStatus phase={p} />);
expect(bars().map((b) => b.style.background).join('|')).toBe(gen);
}
await render(<JobStatus phase="something-new" />);
expect(bars()).toHaveLength(0);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, test, expect } from 'vitest';
import { buildSitePagesPayload } from './site-pages';
import type { PageData } from '../types';
const pg = (id: string, name: string, slug: string, nav?: PageData['nav']): PageData => ({ id, name, slug, craftState: null, nav });
describe('buildSitePagesPayload', () => {
test('landing page is "/", others "/<slug>", in page order, title = nav label or name', () => {
const out = buildSitePagesPayload([pg('a', 'Home', 'index'), pg('b', 'About us', 'about', { label: 'About' } as any), pg('c', 'Menu', 'menu')]);
expect(out).toEqual([
{ title: 'Home', path: '/' },
{ title: 'About', path: '/about' },
{ title: 'Menu', path: '/menu' },
]);
});
test('hidden-from-nav pages are included', () => {
expect(buildSitePagesPayload([pg('a', 'Home', 'index'), pg('b', 'Secret', 'secret', { show: false } as any)])).toHaveLength(2);
});
test('titles are cut to 80 chars and the list to 30 pages', () => {
const many = Array.from({ length: 40 }, (_, i) => pg(`p${i}`, 'x'.repeat(100), `s${i}`));
const out = buildSitePagesPayload(many);
expect(out).toHaveLength(30);
expect(out[0].title).toHaveLength(80);
});
test('no pages -> empty array', () => { expect(buildSitePagesPayload([])).toEqual([]); });
});
+15
View File
@@ -0,0 +1,15 @@
import type { PageData } from '../types';
import { pageHref } from './nav-links';
export interface SitePageRef { title: string; path: string }
/**
* The site's pages as sent to Sitesmith (`site_pages`): every page in page order, hidden-from-nav pages
* included, path = the published href (nav-links pageHref). Capped at 30 pages / 80-char titles (contract).
*/
export function buildSitePagesPayload(pages: PageData[]): SitePageRef[] {
return pages.slice(0, 30).map((p, i) => ({
title: String(p.nav?.label || p.name || '').slice(0, 80),
path: pageHref(p, i),
}));
}