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:
1 parent
28119f0666
commit
201486f357
6 files changed
+127
-6
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]); });
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user