open-design/apps/daemon/tests/artifact-publication-guard.test.ts
lefarcen c80acfefeb
fix(daemon,web): block pitch-deck placeholder publishes and unbreak framework decks (#2384)
Two preview-time bugs surfaced ahead of 0.8.0:

1. Pitch-deck example (#2215): the official html-ppt-pitch-deck prompt asked
   the agent to confirm three facts first, but the manifest had no
   structured `od.inputs`, so the platform's required-input gate had no
   fields to enforce and the run could publish HTML that still contained
   unresolved fundraising placeholders (`Name to confirm`, `$X.XM`,
   `Replace this panel with`, ...). Add structured required inputs to the
   manifest and a daemon-side publication guard that rejects HTML/deck
   artifact writes whose body still contains those placeholders. Scope is
   the file-write boundary only (no assistant-text scanning), so the
   guard cannot trip on the agent's chat prose mid-clarification.

2. Framework deck preview off-screen: `injectDeckBridge` injected
   `place-content: center !important` on `.deck-shell` for every deck-mode
   srcdoc, which forced the framework's `display: grid` shell to re-center
   its implicit track. The framework's `fit()` already centers a
   `transform-origin: top left` stage with an explicit `translate(tx, ty)`
   that assumes the stage's natural layout position is (0, 0); the two
   centerings stacked and the scaled stage landed ~1000px off-screen, so
   the preview showed a sliver of slide content in the top-left with the
   rest black. Skip the override when the framework's `id="deck-stage"`
   marker is in the doc, and drop the dead `display: grid; place-items:
   center` from the deck framework template so future drift can't
   re-introduce the same stack.
2026-05-20 16:20:34 +08:00

186 lines
7.1 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
ArtifactPublicationBlockedError,
findUnresolvedArtifactPlaceholders,
isPublicationGuardedArtifactKind,
shouldBlockArtifactPublication,
} from '../src/artifact-publication-guard.js';
import { listFiles, writeProjectFile } from '../src/projects.js';
const deckManifest = {
kind: 'deck',
renderer: 'deck-html',
title: 'Pitch deck',
exports: ['html', 'pdf'],
metadata: { identifier: 'pitch-deck' },
};
const htmlManifest = {
kind: 'html',
renderer: 'html',
title: 'Pitch HTML',
exports: ['html'],
metadata: { identifier: 'pitch-html' },
};
const markdownManifest = {
kind: 'markdown',
renderer: 'markdown',
title: 'Notes',
exports: ['md'],
metadata: { identifier: 'pitch-notes' },
};
describe('artifact publication guard — placeholder detection', () => {
it('finds every shipped placeholder when present in generated HTML', () => {
const html = `
<!doctype html>
<html>
<body>
<section>Name to confirm</section>
<section>$X.XM</section>
<section>Replace this panel with actual pipeline once known.</section>
<section>Replace role placeholders with leadership names.</section>
<section>Your form answer only said "seed deck".</section>
</body>
</html>
`;
expect(findUnresolvedArtifactPlaceholders(html)).toEqual([
'Name to confirm',
'$X.XM',
'Replace this panel with',
'Replace role placeholders',
'Your form answer only said',
]);
expect(shouldBlockArtifactPublication(html)).toBe(true);
});
it('passes pitch-deck content that already carries concrete ask and traction copy', () => {
const html = `
<!doctype html>
<html>
<body>
<section>Acme AI turns support transcripts into shipped product fixes.</section>
<section>$4.5M seed round</section>
<section>42% MoM revenue growth, 18 enterprise pilots, 91% retention.</section>
<section>Use of funds: engineering, GTM, compliance, and customer success.</section>
</body>
</html>
`;
expect(findUnresolvedArtifactPlaceholders(html)).toEqual([]);
expect(shouldBlockArtifactPublication(html)).toBe(false);
});
it('reads Buffer and Uint8Array bodies, returning [] for non-text inputs', () => {
const placeholderBuffer = Buffer.from('<html>Name to confirm</html>', 'utf8');
expect(findUnresolvedArtifactPlaceholders(placeholderBuffer)).toEqual(['Name to confirm']);
const placeholderBytes = new Uint8Array(Buffer.from('<html>$X.XM</html>', 'utf8'));
expect(findUnresolvedArtifactPlaceholders(placeholderBytes)).toEqual(['$X.XM']);
expect(findUnresolvedArtifactPlaceholders(null)).toEqual([]);
expect(findUnresolvedArtifactPlaceholders(undefined)).toEqual([]);
expect(findUnresolvedArtifactPlaceholders({ unknown: true })).toEqual([]);
});
it('only guards html and deck artifact kinds, not markdown / code / etc.', () => {
expect(isPublicationGuardedArtifactKind('html')).toBe(true);
expect(isPublicationGuardedArtifactKind('deck')).toBe(true);
expect(isPublicationGuardedArtifactKind('markdown')).toBe(false);
expect(isPublicationGuardedArtifactKind('code-snippet')).toBe(false);
expect(isPublicationGuardedArtifactKind('sketch')).toBe(false);
expect(isPublicationGuardedArtifactKind(undefined)).toBe(false);
expect(isPublicationGuardedArtifactKind(null)).toBe(false);
});
});
describe('artifact publication guard — wired into writeProjectFile', () => {
it('rejects html artifacts that still contain pitch-deck placeholders', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-html-'));
try {
await expect(
writeProjectFile(
projectsRoot,
'project-1',
'pitch-deck.html',
Buffer.from('<html><body><section>Name to confirm</section><section>$X.XM</section></body></html>'),
{ artifactManifest: htmlManifest } as unknown as Parameters<typeof writeProjectFile>[4],
),
).rejects.toBeInstanceOf(ArtifactPublicationBlockedError);
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html.artifact.json');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('rejects deck artifacts that still contain pitch-deck placeholders', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-deck-'));
try {
await expect(
writeProjectFile(
projectsRoot,
'project-1',
'pitch-deck.html',
Buffer.from('<html><body>Replace this panel with the real chart.</body></html>'),
{ artifactManifest: deckManifest } as unknown as Parameters<typeof writeProjectFile>[4],
),
).rejects.toMatchObject({
code: 'ARTIFACT_PUBLICATION_BLOCKED',
placeholders: ['Replace this panel with'],
});
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('lets non-guarded artifact kinds pass even when their body contains placeholder substrings', async () => {
// Markdown drafts can legitimately call out unresolved fields with the
// same words; the guard is HTML/deck only. The body here would have
// tripped the guard if applied to all kinds.
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-md-'));
try {
const meta = await writeProjectFile(
projectsRoot,
'project-1',
'critique.md',
Buffer.from('# Critique\n\n- Name to confirm\n- $X.XM\n'),
{ artifactManifest: markdownManifest } as unknown as Parameters<typeof writeProjectFile>[4],
);
expect(meta).toMatchObject({ name: 'critique.md' });
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('passes a clean deck artifact through writeProjectFile', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-clean-'));
try {
const meta = await writeProjectFile(
projectsRoot,
'project-1',
'final-deck.html',
Buffer.from(
'<html><body><section>Acme AI · $4.5M seed</section><section>42% MoM growth · 18 enterprise pilots</section></body></html>',
),
{ artifactManifest: deckManifest } as unknown as Parameters<typeof writeProjectFile>[4],
);
expect(meta).toMatchObject({ name: 'final-deck.html' });
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).toContain('final-deck.html');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
});