mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(od-next): continue plans with keyed markers and honor explicit deliverables * fix(od-next): retire output contracts and hand off continued tasks * fix(od-next): record round settlement and recover production cold * fix(od-next): resume marker sessions with context deltas * fix(od-next): separate completion facts and honor plan-only intent * fix(od-next): preserve physical failures and legacy chat compatibility * test(web): align run recovery fixtures and await campaign mount * fix(od-next): tolerate terminal production marker formatting * fix(od-next): unblock follow-ups after a stop or unreadable history (#8423) Treat a previous task this build cannot read as absent so the follow-up starts a new task, and stop counting a Run with a pending cancel as holding its conversation. Add end-to-end coverage for the marker continuation flow and restore the remote Workspace directory gate cases. * feat(od-next): add deliverable discovery and ten-type routing * fix(od-next): refine discovery and live delivery evidence * feat(observability): expose actual task delivery receipts for evaluation * fix(observability): settle successful headless task telemetry * docs: clarify focused local validation Keep local daemon and web validation scoped to affected files and integration boundaries. Reserve full suites for PR CI unless the user explicitly requests them. * feat: add Motion Design skill and OD Next task route * fix(web): rename motion design entry and place it second * fix(web): reconcile motion design catalog consumers * feat(web): add motion design templates to Home and Community Five motion design example templates (3D procedural island, explainer, product launch, showreel, data story), chosen by community reach, under the Home motion-design chip and a new Community "Motion design" type. Each template follows the bundled example layout: an OD-generated example.html with its runtime libraries under assets/, an HTML preview baked by the existing preview pipeline, and a SKILL.md that carries example.html as the reference when the card is picked on the OD Next motion-design route. Home cards ship curated copy for every UI locale. Motion templates stay off the Video and HyperFrames chips. * fix(skills): keep motion-design procedural layers visible in video export The MP4 exporter seeks timelines with callbacks suppressed (renderSeek(t, { suppressEvents: true })), so content drawn only from a GSAP onUpdate callback is missing from exported video. Recommend a per-frame draw loop that reads the timeline clock, and state that visible content must not depend on timeline callbacks. The OD Next task asset is re-synced with scripts/sync-motion-design-skill.ts. * fix(motion): preserve reference assets and exported animation frames * feat(skills): integrate curated motion helpers with complete runtime files * feat(skills): add Huashu, Oil Motion and Motion.Lab adapters * test: classify animate design guidance in narration guard * feat(motion): refine five community templates and portable previews * fix(motion): refine launch transitions and center gallery previews * fix: allow free-form briefs on automatic motion task route * feat(motion): add template soundtracks and audio export * fix(motion): require rendered MP4 alongside editable video source * test(motion): align strategy activation expectation with package version * fix: scope motion delivery and make editable sources playable * fix(motion): bundle source dependencies and valid closing protocol * feat(motion): rebuild the five templates on the most-viewed community posts Re-select topics by single-post views in the latest awesome-opus-5.5-video snapshot and anchor each template to the top eligible post of its genre: camera-focus explainer, showreel, product launch, procedural island and a new wordless animated short that replaces the data story. All five example.html files are hand-authored canvas/Three.js films drawn by one render(t) from the GSAP master timeline, so preview, scrubbing and MP4 export produce identical frames. Each ships an original procedural soundtrack, updated SKILL.md and 19-locale copy; Home order follows reach. * fix(plugins): load bundled fonts in plugin previews Plugin previews blocked every bundled font: the preview CSP had no font-src, and relative url() references inside inline <style> blocks kept pointing at the preview URL instead of the plugin folder. Allow same-origin fonts, map inline-style url() references through the existing asset route (escapes, absolute and data: URLs stay untouched), and serve font files with font MIME types. * feat(community): make motion template Use match the Home example pick Community 「做同款」 on a motion design template now binds it as that type's own example card, carrying the type's project metadata, so the run takes the same OD Next motion-design route as a pick from Home's motion design list and delivers the MP4 together with the editable source. Other task types keep the existing plugin-pinning hand-off. Community use clicks now record use_source (card | detail_modal) and use_action (use | use_with_query); the details modal Use is tracked for the first time. The Home hand-off no longer records a second community_gallery click for uses Community already tracked. * fix(plugins): count the five motion templates in the official registry metadata * fix(media): keep HyperFrames audio filters compatible with FFmpeg 4.1 CI's bundled Linux FFmpeg 4.1 rejects adelay all=1 and amix normalize=0. Make each track stereo and delay both channels explicitly, skip amix for a single track, and undo amix averaging with a gain for several tracks. * fix(motion): keep template previews bakeable on slow CI renderers CI's software-GL baker skipped the 3D island (preview readiness timeout): under slow frames GSAP lag smoothing slowed the timeline, so the 8 s poster frame never arrived within the baker's 15 s wait. Play every template on the wall clock and move the island poster to 6 s. With simulated 300 ms frames all five now report ready within 10 s. * feat(motion): add the kinetic typography template Template selection now follows the motion design spec's four content types (brand and product films, kinetic typography, explainer animation, procedural art). Kinetic typography takes the fifth slot, replacing the animated short. The new template is a 15-second, 120 BPM type film with an original soundtrack and 19-locale copy. * fix(motion): let the project motion player fit template stages Inside a project the motion source player wraps the composition, fits it and adds its own controls. The templates also scaled their stage, so editable sources from Remix and Community use rendered at a fraction of the preview. Templates now keep their own fit and controls only outside that player. * feat(motion): restage the 3D island as a midday lagoon The island moves from golden hour to a bright tropical midday closer to its anchor post: a shader lagoon coloured by a baked depth map with ripples, shallow-water shimmer and shore foam, a wide white-sand beach, palms and instanced bushes, a village with a pier, soft sun shadows and a lower camera path. The CPU wave mesh is gone, so frames cost no more than before. Copy is updated in all 19 locales. * fix(motion): keep each soundtrack inside the composition root A Community use regenerated the island source tail and dropped the root <audio> element, so its MP4 had no sound. Templates now place the element inside the composition root next to the stage. * docs(motion): state the template selection rule as complete works * fix(motion): enforce video clocks and bound content verification * feat(motion): replace the motion templates with remakes of viral posts The five Motion design templates are now frame-faithful remakes of viral motion posts, so each preview shows the look users came for: the 15-second motion designer showreel, a high-energy motion reel (copy set in English), a one-shape UI motion piece, a pixel-art wizard and a 20-second electric-blue tech reel. Each ships open-licensed fonts, an original soundtrack, the creator's published prompt where one exists, and credits its source post. The explainer, product launch, 3D island and kinetic type templates are removed. Tests follow the new set; a backward-seek check now covers hidden WebGL layers. * feat(motion): feature The Future Is Already Here first and start preview sound with the film * chore: ensure PR CI * fix(motion): match The Future Is Already Here controls and reorder the motion templates * fix(motion): make the one-shape UI preview controls legible * fix(motion): hide The Future Is Already Here controls inside the project player * fix(od-next): maintain discovery prompt assets in English * fix(motion): plan suitable sound and synchronize source audio playback * feat(motion): keep remake soundtracks by default and fade exported audio where it stops * test(motion): cover audible source controls in Chromium * fix(test): use explicit ESM imports in audio player regression * fix(od-next): preserve planning intent and make agent CLI probes inert * fix(motion): align preview track discovery with video export * feat(motion): autoplay looping HTML without a player bar * fix(test): consume the built motion playback contract * docs(motion): state explicit soundtrack rules for make-same and the music line in replies * chore(motion): version the audible looping delivery profile * docs(motion): let template sound rules lead the general policy and freeze the audio contract * fix(motion): scope autoplay to declared video motion sources * fix(motion): reject audible MP4s under an explicit silent contract * docs(motion): drop the routine music note from make-same replies * test(motion): align delivery regressions with looping source playback * fix(od-next): route refreshable sources and preserve live templates * fix(od-next): distinguish source refresh from preview reload * fix(motion): isolate preview and audio export from legacy HyperFrames * fix(od-next): retain live list containers for empty source data * test(motion): retain the existing desktop legacy audio limitation * fix(motion): suspend audio when previews leave the active workspace * feat(motion): replace two motion templates with a product promo and a visual lesson; sign the showreel as Open Design * fix(discovery): distinguish reference recreation from new prototypes * fix(discovery): require skill reads before copying reference artifacts * feat(motion): give the showreel the Open Design palette and a full-size stacked wordmark * feat(motion): give the product promo a livelier mascot girl * refactor(motion): rebuild The Future Is Already Here as readable, editable scene code * fix(motion): pick gallery cover frames that survive the baker's capture delay * feat(motion): sharpen the showreel opener, depth, truchet and end card * fix(discovery): avoid implementation-only skills and false video delivery * feat(motion): make the product promo mascot talk, add a speaking score and the flow pop * fix(discovery): distinguish video rendering from editable animation delivery * feat(motion): give the visual lesson the original's paper-collage texture * fix(motion): bundle gallery covers for fresh branch checkouts * feat(motion): feature and refine Bring Ideas to Life Adapt the paper story around OpenDesign capabilities, with short rounded hands, clearer timing, continuous paper actions, and a larger audience invitation. Feature it first in Motion and synchronize localized names, metadata, cover, guidance, and regressions. Keep product promo audio music-only with sparse percussion. * fix(motion): stabilize artifact cards and capture declared covers * fix(discovery): retain WebGL intent and isolate reference-only skills * fix(discovery): complete authorized plans and verify audible delivery * fix(discovery): gate skill reads on current production authorization * fix(web): share creation type order with community * fix(discovery): stop required-model attempts on authentication failure * fix(discovery): retain required model provider and billing channel * fix(discovery): stop opaque video failures without diagnostic generations * fix(discovery): distinguish local state and tighten output adherence * fix(preview): preserve local forms and hash routes in strict sandbox * fix(prototype): keep validation feedback selectors consistent * fix(prototype): preserve native hidden feedback states * fix(discovery): align local routes and live filter states * fix(prototype): preserve dynamic controls and dialog visibility * fix(prototype): keep review and confirmation state coherent * fix(web): stabilize preview activation and retain scoped local progress * test(web): verify persistent previews through activation messages * feat(web): refine task type selection and keyboard navigation * fix(discovery): distinguish execution from requested delivery and expose recovery * fix(web): use final run delivery state in folded task history * revert(web): restore todo-based continuation controls * revert(od-next): remove experimental continuation recovery changes * fix(telemetry): bind Task traces to authenticated Vela identity * test(telemetry): establish accepted Task roots in bridge fixtures * test(discovery): align required CI fixtures with integrated behavior * fix(web): preserve succeeded messages when run probe is unavailable * test(discovery): verify selection clearing and production turn recovery --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com> Co-authored-by: Cheems <hihicheems@gmail.com> Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> Co-authored-by: zhouzheng <zhouzheng@refly.ai> Co-authored-by: William <zhouhaoran@refly.ai> Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com> Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
497 lines
22 KiB
TypeScript
497 lines
22 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
inferBaselineHtmlEntry,
|
|
validateProjectDeliverable,
|
|
validateRunDeliverable,
|
|
} from '../src/run-deliverable-validation.js';
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
async function projectFixture(
|
|
files: Record<string, string>,
|
|
): Promise<{ projectsRoot: string; projectId: string }> {
|
|
const projectsRoot = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'od-deliverable-validation-'),
|
|
);
|
|
temporaryRoots.push(projectsRoot);
|
|
const projectId = 'project-1';
|
|
const projectRoot = path.join(projectsRoot, projectId);
|
|
await fs.mkdir(projectRoot, { recursive: true });
|
|
for (const [relativePath, content] of Object.entries(files)) {
|
|
const target = path.join(projectRoot, relativePath);
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
await fs.writeFile(target, content, 'utf8');
|
|
}
|
|
return { projectsRoot, projectId };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryRoots.splice(0).map((root) =>
|
|
fs.rm(root, { recursive: true, force: true }),
|
|
),
|
|
);
|
|
});
|
|
|
|
describe('run deliverable validation', () => {
|
|
it('recognizes a current independent image without changing the existing prototype entry', async () => {
|
|
const fixture = await projectFixture({ 'index.html': '<main>Existing page</main>', 'assets/dog.png': 'image' });
|
|
const input = { ...fixture, runStatus: 'succeeded' as const, artifactCount: 1,
|
|
projectMetadata: { kind: 'prototype' as const, entryFile: 'index.html' }, touchedPaths: ['assets/dog.png'] };
|
|
expect(await validateRunDeliverable(input)).toMatchObject({ valid: false, validation: 'entry_not_touched' });
|
|
expect(await validateRunDeliverable({ ...input, allowIndependentOutput: true })).toMatchObject({
|
|
valid: true, entryFile: 'assets/dog.png', artifactKind: 'image',
|
|
});
|
|
expect(input.projectMetadata.entryFile).toBe('index.html');
|
|
});
|
|
|
|
it('does not count an old image or an ambiguous set of files as an independent delivery', async () => {
|
|
const fixture = await projectFixture({ 'dog.png': 'image', 'cat.png': 'image' });
|
|
const input = { ...fixture, runStatus: 'succeeded' as const, artifactCount: 1,
|
|
projectMetadata: { kind: 'prototype' as const }, allowIndependentOutput: true };
|
|
for (const touchedPaths of [[], ['../dog.png'], ['missing.png'], ['dog.png', 'cat.png']]) {
|
|
expect((await validateRunDeliverable({ ...input, touchedPaths })).valid).toBe(false);
|
|
}
|
|
expect((await validateRunDeliverable(input)).valid).toBe(false);
|
|
});
|
|
|
|
it('accepts multiple Discovery outputs only when each touched candidate is readable', async () => {
|
|
const fixture = await projectFixture({ 'slides.html': '<main>Deck</main>', 'manual.html': '<main>Manual</main>' });
|
|
const input = { ...fixture, runStatus: 'succeeded' as const, artifactCount: 2,
|
|
touchedPaths: ['slides.html', 'manual.html'], allowIndependentOutput: true, allowMultipleOutputCandidates: true };
|
|
expect(await validateRunDeliverable(input)).toMatchObject({ valid: true, entryFile: 'manual.html' });
|
|
expect((await validateRunDeliverable({ ...input, touchedPaths: ['slides.html', 'missing.html'] })).valid).toBe(false);
|
|
expect((await validateRunDeliverable({ ...input, touchedPaths: ['slides.html', '../outside.html'] })).valid).toBe(false);
|
|
});
|
|
|
|
it('keeps HTML as the entry when the current run integrates an image into a page', async () => {
|
|
const fixture = await projectFixture({ 'index.html': '<img src="dog.png">', 'dog.png': 'image' });
|
|
expect(await validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 2,
|
|
projectMetadata: { kind: 'prototype' }, touchedPaths: ['index.html', 'dog.png'], allowIndependentOutput: true,
|
|
})).toMatchObject({ valid: true, entryFile: 'index.html', artifactKind: 'html' });
|
|
});
|
|
|
|
it.each(['prototype', 'deck', 'image', 'video'] as const)(
|
|
'allows a real standalone image in a %s project only with current-run evidence', async (kind) => {
|
|
const fixture = await projectFixture({ 'dog.png': 'image' });
|
|
const input = { ...fixture, artifactCount: 1, projectMetadata: { kind },
|
|
touchedPaths: ['dog.png'], allowIndependentOutput: true };
|
|
expect(await validateRunDeliverable({ ...input, runStatus: 'succeeded' }))
|
|
.toMatchObject({ valid: true, entryFile: 'dog.png' });
|
|
for (const runStatus of ['failed', 'canceled'] as const) {
|
|
expect((await validateRunDeliverable({ ...input, runStatus })).valid).toBe(false);
|
|
}
|
|
expect((await validateRunDeliverable({ ...input, runStatus: 'succeeded', artifactCount: 0 })).valid).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('does not accept an independent output symlink outside the project', async () => {
|
|
const fixture = await projectFixture({});
|
|
await fs.writeFile(path.join(fixture.projectsRoot, 'outside.png'), 'external');
|
|
await fs.symlink('../outside.png', path.join(fixture.projectsRoot, fixture.projectId, 'dog.png'));
|
|
expect((await validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
projectMetadata: { kind: 'prototype' }, touchedPaths: ['dog.png'], allowIndependentOutput: true,
|
|
})).valid).toBe(false);
|
|
});
|
|
it('accepts a readable entry whose file kind matches the project kind', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Ready</title>',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
projectMetadata: {
|
|
kind: 'prototype',
|
|
entryFile: 'index.html',
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: true,
|
|
validation: 'valid',
|
|
entryFile: 'index.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
|
|
it('rejects a stale declared entry even when an unrelated artifact was touched', async () => {
|
|
const fixture = await projectFixture({
|
|
'notes.txt': 'unrelated run output',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
projectMetadata: {
|
|
kind: 'prototype',
|
|
entryFile: 'index.html',
|
|
},
|
|
}),
|
|
).resolves.toEqual({
|
|
valid: false,
|
|
validation: 'entry_missing',
|
|
});
|
|
});
|
|
|
|
it('rejects an old declared entry when this run only touched another artifact', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Old entry</title>',
|
|
'other.html': '<!doctype html><title>Unrelated output</title>',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
touchedPaths: ['other.html'],
|
|
projectMetadata: {
|
|
kind: 'prototype',
|
|
entryFile: 'index.html',
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: false,
|
|
validation: 'entry_not_touched',
|
|
entryFile: 'index.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
|
|
it('rejects a readable entry whose file kind does not match the project kind', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Wrong kind</title>',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
projectMetadata: {
|
|
kind: 'image',
|
|
entryFile: 'index.html',
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: false,
|
|
validation: 'type_mismatch',
|
|
entryFile: 'index.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
|
|
// Issue: a HyperFrames run delivered an editable root `index.html`, the run
|
|
// succeeded, and delivery validation still reported `type_mismatch` — which
|
|
// the OD Next coordinator turns into `od_next_canonical_deliverable_invalid`
|
|
// and surfaces to the user as "The strategy task could not continue."
|
|
//
|
|
// HyperFrames is an HTML-to-video renderer: the composition HTML *is* the
|
|
// authored deliverable and the MP4 is a render of it. The project rides on
|
|
// `kind: 'video'` only because that is the Home surface it was created from.
|
|
describe('hyperframes projects', () => {
|
|
const hyperFramesMetadata = {
|
|
kind: 'video' as const,
|
|
intent: 'hyperframes' as const,
|
|
videoModel: 'hyperframes-html',
|
|
};
|
|
|
|
it('accepts the authored composition html as the canonical deliverable', async () => {
|
|
// Mirrors the real project layout: the scaffolded composition lives in a
|
|
// dot-directory, which `listFiles` skips, so the root `index.html` the
|
|
// agent writes is the only candidate delivery validation can ever see.
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Kinetic typography opener</title>',
|
|
'.hyperframes-cache/opener/hyperframes.json': '{}',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
touchedPaths: ['index.html'],
|
|
projectMetadata: hyperFramesMetadata,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: true,
|
|
validation: 'valid',
|
|
entryFile: 'index.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
|
|
it('accepts a rendered mp4 for the same project', async () => {
|
|
const fixture = await projectFixture({
|
|
'opener.mp4': 'not-really-an-mp4',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
touchedPaths: ['opener.mp4'],
|
|
projectMetadata: hyperFramesMetadata,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: true,
|
|
validation: 'valid',
|
|
entryFile: 'opener.mp4',
|
|
artifactKind: 'video',
|
|
});
|
|
});
|
|
|
|
it('still rejects html for a generative video project', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Not a video</title>',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 1,
|
|
touchedPaths: ['index.html'],
|
|
projectMetadata: { kind: 'video', videoModel: 'fal/veo-3' },
|
|
}),
|
|
).resolves.toMatchObject({
|
|
valid: false,
|
|
validation: 'type_mismatch',
|
|
entryFile: 'index.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('does not promote a Studio route or pre-existing file without a run artifact', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<!doctype html><title>Old artifact</title>',
|
|
});
|
|
|
|
await expect(
|
|
validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 0,
|
|
projectMetadata: {
|
|
kind: 'prototype',
|
|
entryFile: 'index.html',
|
|
},
|
|
}),
|
|
).resolves.toEqual({
|
|
valid: false,
|
|
validation: 'no_artifact',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('linked prototype page delivery (OPEND-2887)', () => {
|
|
it('accepts a touched page reachable from the unchanged canonical entry', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<a href="pages/catalog.html?edition=1#plants">Catalog</a>',
|
|
'pages/catalog.html': '<title>Catalog</title>',
|
|
});
|
|
await expect(validateRunDeliverable({
|
|
...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
touchedPaths: ['pages/catalog.html'],
|
|
projectMetadata: { kind: 'prototype', entryFile: 'index.html' },
|
|
})).resolves.toMatchObject({ valid: true, entryFile: 'index.html' });
|
|
});
|
|
|
|
it('follows nested local navigation without accepting a disconnected page', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<a href="pages/catalog.html">Catalog</a>',
|
|
'pages/catalog.html': '<a href="../index.html">Home</a><a href="plants.html">Plants</a>',
|
|
'pages/plants.html': '<title>Plants</title>',
|
|
'unrelated.html': '<title>Unrelated</title>',
|
|
});
|
|
const input = {
|
|
...fixture, runStatus: 'succeeded' as const, artifactCount: 1,
|
|
projectMetadata: { kind: 'prototype' as const, entryFile: 'index.html' },
|
|
};
|
|
await expect(validateRunDeliverable({ ...input, touchedPaths: ['pages/plants.html'] }))
|
|
.resolves.toMatchObject({ valid: true });
|
|
await expect(validateRunDeliverable({ ...input, touchedPaths: ['unrelated.html'] }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_not_touched' });
|
|
});
|
|
});
|
|
|
|
describe('prototype delivery boundaries', () => {
|
|
it('keeps the pre-run homepage when a linked index.html is created', async () => {
|
|
const fixture = await projectFixture({
|
|
'landing.html': '<a href="index.html">Catalog</a>',
|
|
'index.html': '<title>Catalog</title>',
|
|
});
|
|
const input = {
|
|
...fixture, runStatus: 'succeeded' as const, artifactCount: 1,
|
|
touchedPaths: ['index.html'], baselineEntryFile: 'landing.html',
|
|
};
|
|
await expect(validateRunDeliverable({ ...input, projectMetadata: { kind: 'prototype' } }))
|
|
.resolves.toMatchObject({ valid: true, entryFile: 'landing.html', linkedPage: 'index.html' });
|
|
await expect(validateRunDeliverable({ ...input, projectMetadata: { kind: 'prototype', entryFile: 'index.html' } }))
|
|
.resolves.toEqual({ valid: true, validation: 'valid', entryFile: 'index.html', artifactKind: 'html' });
|
|
});
|
|
|
|
it('binds a unique pre-run HTML entry, without guessing among existing pages', () => {
|
|
const root = path.resolve('fixture-project');
|
|
expect(inferBaselineHtmlEntry(root, [path.join(root, 'landing.html')])).toBe('landing.html');
|
|
expect(inferBaselineHtmlEntry(root, [path.join(root, 'landing.html'), path.join(root, 'catalog.html')])).toBeUndefined();
|
|
expect(inferBaselineHtmlEntry(root, [path.join(root, 'index.html'), path.join(root, 'catalog.html')])).toBe('index.html');
|
|
});
|
|
|
|
it('retains the observed entry when a second page is added, but cannot replace a stale declared entry', async () => {
|
|
const fixture = await projectFixture({
|
|
'landing.html': '<a href="catalog.html">Catalog</a>', 'catalog.html': '<title>Catalog</title>',
|
|
});
|
|
const baseInput = { ...fixture, runStatus: 'succeeded' as const, artifactCount: 1 };
|
|
const input = { ...baseInput,
|
|
touchedPaths: ['catalog.html'], baselineEntryFile: 'landing.html' };
|
|
await expect(validateRunDeliverable({ ...input, projectMetadata: { kind: 'prototype' } }))
|
|
.resolves.toMatchObject({ valid: true, entryFile: 'landing.html', linkedPage: 'catalog.html' });
|
|
await expect(validateRunDeliverable({ ...input, projectMetadata: { kind: 'prototype', entryFile: 'missing.html' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_missing' });
|
|
await expect(validateRunDeliverable({ ...input, artifactCount: 0, touchedPaths: [], projectMetadata: { kind: 'prototype' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'no_artifact' });
|
|
await expect(validateRunDeliverable({ ...baseInput, touchedPaths: input.touchedPaths, projectMetadata: { kind: 'prototype' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_missing' });
|
|
await expect(validateRunDeliverable({ ...baseInput, baselineEntryFile: input.baselineEntryFile, projectMetadata: { kind: 'prototype' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_missing' });
|
|
await expect(validateRunDeliverable({ ...input, projectMetadata: { kind: 'template' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_missing' });
|
|
});
|
|
|
|
it.each([
|
|
'<a href="https://example.com/catalog.html">Remote</a>',
|
|
'<a href="//example.com/catalog.html">Remote</a>',
|
|
'<base href="https://example.com/"><a href="catalog.html">Remote base</a>',
|
|
'<!-- <a href="catalog.html">Comment</a> -->',
|
|
`<script>const html = '<a href="catalog.html">Not markup</a>';</script>`,
|
|
])('does not treat external URLs or inert text as delivery: %s', async (entry) => {
|
|
const fixture = await projectFixture({ 'index.html': entry, 'catalog.html': '<title>Catalog</title>' });
|
|
await expect(validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
touchedPaths: ['catalog.html'], projectMetadata: { kind: 'prototype', entryFile: 'index.html' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_not_touched' });
|
|
});
|
|
|
|
it('resolves local base URLs and URL-encoded page names', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<base href="/pages/"><a href="plant%20guide.html">Guide</a>',
|
|
'pages/plant guide.html': '<title>Guide</title>',
|
|
});
|
|
await expect(validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
touchedPaths: ['pages/plant guide.html'], projectMetadata: { kind: 'prototype', entryFile: 'index.html' } }))
|
|
.resolves.toMatchObject({ valid: true });
|
|
});
|
|
|
|
it('declines navigation evidence that exceeds the page read budget', async () => {
|
|
const fixture = await projectFixture({
|
|
'index.html': '<a href="catalog.html">Catalog</a>' + ' '.repeat(512 * 1024),
|
|
'catalog.html': '<title>Catalog</title>',
|
|
});
|
|
await expect(validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
touchedPaths: ['catalog.html'], projectMetadata: { kind: 'prototype', entryFile: 'index.html' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_not_touched' });
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')('never reads a linked page through a symlink outside the project', async () => {
|
|
const fixture = await projectFixture({ 'index.html': '<a href="catalog.html">Catalog</a>' });
|
|
const external = await projectFixture({ 'outside.html': '<title>Outside</title>' });
|
|
await fs.symlink(path.join(external.projectsRoot, external.projectId, 'outside.html'),
|
|
path.join(fixture.projectsRoot, fixture.projectId, 'catalog.html'));
|
|
await expect(validateRunDeliverable({ ...fixture, runStatus: 'succeeded', artifactCount: 1,
|
|
touchedPaths: ['catalog.html'], projectMetadata: { kind: 'prototype', entryFile: 'index.html' } }))
|
|
.resolves.toMatchObject({ valid: false, validation: 'entry_not_touched' });
|
|
});
|
|
});
|
|
|
|
|
|
// A completed project can be presented after a later run that writes nothing.
|
|
// Keep this project-state check separate from the strict run acceptance gate.
|
|
describe('project deliverable validation', () => {
|
|
it('resolves the entry a run did not touch this round', async () => {
|
|
const fixture = await projectFixture({
|
|
'qingdao-travel-guide.html': '<!doctype html><title>Done</title>',
|
|
});
|
|
const metadata = { kind: 'deck' as const };
|
|
|
|
await expect(validateRunDeliverable({
|
|
...fixture,
|
|
runStatus: 'succeeded',
|
|
artifactCount: 0,
|
|
touchedPaths: [],
|
|
projectMetadata: metadata,
|
|
})).resolves.toMatchObject({ valid: false, validation: 'no_artifact' });
|
|
|
|
await expect(validateProjectDeliverable({ ...fixture, projectMetadata: metadata }))
|
|
.resolves.toMatchObject({
|
|
valid: true,
|
|
validation: 'valid',
|
|
entryFile: 'qingdao-travel-guide.html',
|
|
artifactKind: 'html',
|
|
});
|
|
});
|
|
|
|
it('still refuses a project with no compatible entry', async () => {
|
|
const fixture = await projectFixture({ 'notes.md': '# nothing runnable' });
|
|
await expect(validateProjectDeliverable({
|
|
...fixture,
|
|
projectMetadata: { kind: 'deck' },
|
|
})).resolves.toMatchObject({ valid: false, validation: 'type_mismatch' });
|
|
});
|
|
|
|
it('still refuses a declared entry that is gone', async () => {
|
|
const fixture = await projectFixture({
|
|
'other.html': '<!doctype html><title>Not the entry</title>',
|
|
});
|
|
await expect(validateProjectDeliverable({
|
|
...fixture,
|
|
projectMetadata: { kind: 'deck', entryFile: 'index.html' },
|
|
})).resolves.toMatchObject({ valid: false, validation: 'entry_missing' });
|
|
});
|
|
|
|
it('refuses a project it cannot identify', async () => {
|
|
const fixture = await projectFixture({ 'index.html': '<!doctype html>' });
|
|
await expect(validateProjectDeliverable({
|
|
projectsRoot: fixture.projectsRoot,
|
|
projectId: null,
|
|
projectMetadata: { kind: 'deck' },
|
|
})).resolves.toMatchObject({ valid: false, validation: 'project_missing' });
|
|
});
|
|
});
|
|
|
|
|
|
describe('motion output facts without a planning-contract gate', () => {
|
|
it('accepts Query-selected Motion output in a project entered through generic Video', async () => {
|
|
const fixture = await projectFixture({
|
|
'motion-source/film/index.html': '<main>Editable film source</main>',
|
|
'film.mp4': 'video',
|
|
});
|
|
// Export bytes are checked by the exporter; completion must not demand a
|
|
// Motion planning contract or rewrite the original project type.
|
|
expect(await validateRunDeliverable({ ...fixture,
|
|
projectMetadata: { kind: 'video' }, runStatus: 'succeeded', artifactCount: 2,
|
|
allowIndependentOutput: true,
|
|
touchedPaths: ['motion-source/film/index.html', 'film.mp4'],
|
|
})).toMatchObject({ valid: true });
|
|
});
|
|
|
|
it('recognizes nested editable source without requiring model-authored plan fields', async () => {
|
|
const fixture = await projectFixture({ 'motion-source/film/index.html': '<main>Editable film source</main>', 'film.mp4': 'video' });
|
|
expect(await validateRunDeliverable({ ...fixture, projectMetadata: { kind: 'video', intent: 'motion-design', videoModel: 'hyperframes-html' }, runStatus: 'succeeded', artifactCount: 2 })).toMatchObject({ valid: true, entryFile: 'motion-source/film/index.html' });
|
|
});
|
|
it('keeps file evidence separate from missing video export', async () => {
|
|
const fixture = await projectFixture({ 'index.html': '<main>Partial editable source</main>' });
|
|
expect((await validateRunDeliverable({ ...fixture, projectMetadata: { kind: 'video', intent: 'motion-design', videoModel: 'hyperframes-html' }, runStatus: 'succeeded', artifactCount: 1 })).valid).toBe(true);
|
|
});
|
|
});
|