feat: Phase 0 workspace, spikes, and golden harness
Stand up the monorepo skeleton and land the first Phase 0 artifacts: pairing threat model, plugin catalog, builder detect, 20-prompt golden harness, and synthetic 2GB mirror timing.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { SiteFixture, ToolCall } from './types.ts';
|
||||
|
||||
function replaceHeading(html: string, newText: string): string {
|
||||
if (!/<h1\b/i.test(html)) {
|
||||
return `<h1>${newText}</h1>${html}`;
|
||||
}
|
||||
return html.replace(/<h1\b[^>]*>[\s\S]*?<\/h1>/i, `<h1>${newText}</h1>`);
|
||||
}
|
||||
|
||||
function patchElementorHeading(meta: Record<string, string>, newText: string): Record<string, string> {
|
||||
const raw = meta._elementor_data;
|
||||
if (raw === undefined) {
|
||||
return meta;
|
||||
}
|
||||
try {
|
||||
const data: unknown = JSON.parse(raw);
|
||||
const next = JSON.stringify(data, (key, value) => {
|
||||
if (key === 'title' && typeof value === 'string') {
|
||||
return newText;
|
||||
}
|
||||
return value as unknown;
|
||||
});
|
||||
return { ...meta, _elementor_data: next };
|
||||
} catch {
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
function pageOf(site: SiteFixture, slug: string) {
|
||||
const post = site.posts.find((item) => item.slug === slug);
|
||||
if (post === undefined) {
|
||||
throw new Error(`unknown page: ${slug}`);
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
export function applyTool(site: SiteFixture, call: ToolCall): SiteFixture {
|
||||
if (call.name === 'update_option') {
|
||||
const key = call.arguments.key;
|
||||
const value = call.arguments.value;
|
||||
if (key === undefined || value === undefined) {
|
||||
throw new Error('update_option requires key and value');
|
||||
}
|
||||
return { ...site, options: { ...site.options, [key]: value } };
|
||||
}
|
||||
|
||||
if (call.name === 'edit_heading') {
|
||||
const page = call.arguments.page;
|
||||
const newText = call.arguments.newText;
|
||||
if (page === undefined || newText === undefined) {
|
||||
throw new Error('edit_heading requires page and newText');
|
||||
}
|
||||
pageOf(site, page);
|
||||
return {
|
||||
...site,
|
||||
posts: site.posts.map((post) =>
|
||||
post.slug === page
|
||||
? {
|
||||
...post,
|
||||
content: replaceHeading(post.content, newText),
|
||||
meta: patchElementorHeading(post.meta, newText),
|
||||
}
|
||||
: post,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (call.name === 'edit_text') {
|
||||
const page = call.arguments.page;
|
||||
const target = call.arguments.target;
|
||||
const replacement = call.arguments.replacement;
|
||||
if (page === undefined || target === undefined || replacement === undefined) {
|
||||
throw new Error('edit_text requires page, target, and replacement');
|
||||
}
|
||||
const current = pageOf(site, page);
|
||||
return {
|
||||
...site,
|
||||
posts: site.posts.map((post) =>
|
||||
post.slug === page
|
||||
? {
|
||||
...post,
|
||||
content: current.content.split(target).join(replacement),
|
||||
meta: Object.fromEntries(
|
||||
Object.entries(post.meta).map(([key, value]) => [key, value.split(target).join(replacement)]),
|
||||
),
|
||||
}
|
||||
: post,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unknown tool: ${call.name}`);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Builder, PluginRef, PostFixture } from './types.ts';
|
||||
|
||||
export type DetectInput = {
|
||||
theme: string;
|
||||
plugins: PluginRef[];
|
||||
posts: Pick<PostFixture, 'content' | 'meta'>[];
|
||||
};
|
||||
|
||||
export function detectBuilder(input: DetectInput): Builder {
|
||||
const active = new Set(input.plugins.filter((plugin) => plugin.active).map((plugin) => plugin.slug));
|
||||
const theme = input.theme.toLowerCase();
|
||||
|
||||
if (
|
||||
active.has('elementor') &&
|
||||
input.posts.some((post) => post.meta._elementor_edit_mode !== undefined || post.meta._elementor_data !== undefined)
|
||||
) {
|
||||
return 'elementor';
|
||||
}
|
||||
|
||||
if (
|
||||
(active.has('beaver-builder-lite-version') || active.has('bb-plugin')) &&
|
||||
input.posts.some((post) => post.meta._fl_builder_data !== undefined || post.meta._fl_builder_enabled !== undefined)
|
||||
) {
|
||||
return 'beaver';
|
||||
}
|
||||
|
||||
if (
|
||||
(theme === 'divi' || active.has('divi-builder')) &&
|
||||
input.posts.some((post) => post.meta._et_pb_use_builder === 'on')
|
||||
) {
|
||||
return 'divi';
|
||||
}
|
||||
|
||||
if (input.posts.some((post) => post.content.includes('<!-- wp:'))) {
|
||||
return 'gutenberg';
|
||||
}
|
||||
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
export function siteInfoPayload(input: DetectInput): { builder: Builder } {
|
||||
return { builder: detectBuilder(input) };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GoldenAssert, SiteFixture } from './types.ts';
|
||||
|
||||
export type AssertionResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
function pageText(site: SiteFixture, slug: string): string {
|
||||
const post = site.posts.find((item) => item.slug === slug);
|
||||
if (post === undefined) {
|
||||
return '';
|
||||
}
|
||||
return `${post.content}\n${Object.values(post.meta).join('\n')}`;
|
||||
}
|
||||
|
||||
export function checkAssertion(site: SiteFixture, assertion: GoldenAssert): AssertionResult {
|
||||
if (assertion.type === 'option') {
|
||||
return { ok: site.options[assertion.key] === assertion.value };
|
||||
}
|
||||
return { ok: pageText(site, assertion.page).includes(assertion.contains) };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ToolCall } from './types.ts';
|
||||
|
||||
export const expectedCalls: Record<string, ToolCall> = {
|
||||
'gb-01': { name: 'edit_heading', arguments: { page: 'homepage', newText: 'Welcome to My Business' } },
|
||||
'gb-02': { name: 'update_option', arguments: { key: 'blogname', value: 'Harbor Dental' } },
|
||||
'gb-03': { name: 'edit_heading', arguments: { page: 'about', newText: 'About the practice' } },
|
||||
'gb-04': {
|
||||
name: 'edit_text',
|
||||
arguments: { page: 'homepage', target: 'Family dentistry', replacement: 'Gentle dentistry' },
|
||||
},
|
||||
'gb-05': { name: 'update_option', arguments: { key: 'blog_public', value: '0' } },
|
||||
'gb-06': { name: 'edit_heading', arguments: { page: 'contact', newText: 'Call us today' } },
|
||||
'gb-07': { name: 'update_option', arguments: { key: 'blogdescription', value: 'Dentistry for the whole family' } },
|
||||
'gb-08': { name: 'edit_heading', arguments: { page: 'services', newText: 'What we offer' } },
|
||||
'gb-09': { name: 'edit_heading', arguments: { page: 'homepage', newText: 'Harbor Dental Home' } },
|
||||
'gb-10': { name: 'edit_heading', arguments: { page: 'about', newText: 'Meet the dentist' } },
|
||||
'el-01': { name: 'edit_heading', arguments: { page: 'homepage', newText: 'Evening table available' } },
|
||||
'el-02': { name: 'update_option', arguments: { key: 'blogname', value: "Nonna's Kitchen" } },
|
||||
'el-03': { name: 'edit_heading', arguments: { page: 'menu', newText: "This week's plates" } },
|
||||
'el-04': {
|
||||
name: 'edit_text',
|
||||
arguments: { page: 'homepage', target: 'Book now', replacement: 'Reserve a table' },
|
||||
},
|
||||
'el-05': { name: 'update_option', arguments: { key: 'blog_public', value: '0' } },
|
||||
'el-06': { name: 'edit_heading', arguments: { page: 'about', newText: 'The family kitchen' } },
|
||||
'el-07': { name: 'update_option', arguments: { key: 'blogdescription', value: 'Pasta, wine, and Sunday gravy' } },
|
||||
'el-08': { name: 'edit_heading', arguments: { page: 'menu', newText: 'Printed menu' } },
|
||||
'el-09': { name: 'edit_heading', arguments: { page: 'hours', newText: 'Open Tue through Sun' } },
|
||||
'el-10': { name: 'edit_heading', arguments: { page: 'location', newText: '14 Harbor Street' } },
|
||||
};
|
||||
|
||||
export function asGrokResponse(call: ToolCall) {
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { GrokResponse } from './types.ts';
|
||||
|
||||
const url = 'https://api.x.ai/v1/chat/completions';
|
||||
|
||||
export async function callGrok(input: {
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
builder: string;
|
||||
}): Promise<GrokResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'grok-3',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You edit a WordPress fixture. Use only edit_heading, edit_text, or update_option. Fill slots. Do not explain.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `site=${input.siteId} builder=${input.builder}\n${input.prompt}`,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_heading',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { page: { type: 'string' }, newText: { type: 'string' } },
|
||||
required: ['page', 'newText'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_text',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
replacement: { type: 'string' },
|
||||
},
|
||||
required: ['page', 'target', 'replacement'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_option',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { key: { type: 'string' }, value: { type: 'string' } },
|
||||
required: ['key', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: 'required',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GrokResponse;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { GoldenPrompt } from './types.ts';
|
||||
|
||||
const goldenRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function loadPrompts(): GoldenPrompt[] {
|
||||
const raw = readFileSync(join(goldenRoot, 'prompts.json'), 'utf8');
|
||||
return JSON.parse(raw) as GoldenPrompt[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { SiteFixture } from './types.ts';
|
||||
|
||||
const goldenRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function loadSite(id: string): SiteFixture {
|
||||
const raw = readFileSync(join(goldenRoot, 'sites', id, 'site.json'), 'utf8');
|
||||
return JSON.parse(raw) as SiteFixture;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SiteExport } from './types.ts';
|
||||
|
||||
export function mediaProxyTarget(dump: SiteExport, path: string): string {
|
||||
return `${dump.origin}${path}`;
|
||||
}
|
||||
|
||||
export function stageReplacement(
|
||||
_dump: SiteExport,
|
||||
path: string,
|
||||
_bytes: number,
|
||||
): { copiedPaths: string[] } {
|
||||
return { copiedPaths: [path] };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectBuilder } from './builder-detect.ts';
|
||||
import { asGrokResponse, expectedCalls } from './expected-calls.ts';
|
||||
import { callGrok } from './grok-client.ts';
|
||||
import { loadPrompts } from './load-prompts.ts';
|
||||
import { loadSite } from './load-site.ts';
|
||||
import { scoreGrokResponse } from './score.ts';
|
||||
|
||||
const goldenRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function key(): string | undefined {
|
||||
const value = process.env.XAI_API_KEY;
|
||||
return value !== undefined && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
const prompts = loadPrompts();
|
||||
const fixtureScores = prompts.map((prompt) => {
|
||||
const call = expectedCalls[prompt.id];
|
||||
if (call === undefined) {
|
||||
throw new Error(`missing expected call for ${prompt.id}`);
|
||||
}
|
||||
const verdict = scoreGrokResponse({
|
||||
site: loadSite(prompt.site),
|
||||
assert: prompt.assert,
|
||||
grok: asGrokResponse(call),
|
||||
});
|
||||
return { id: prompt.id, site: prompt.site, passed: verdict.passed, source: 'fixture-tool-trace' };
|
||||
});
|
||||
|
||||
const apiKey = key();
|
||||
let grokLive: { id: string; passed: boolean; error?: string } | undefined;
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const prompt = prompts[0];
|
||||
if (prompt === undefined) {
|
||||
throw new Error('no prompts');
|
||||
}
|
||||
const site = loadSite(prompt.site);
|
||||
try {
|
||||
const grok = await callGrok({
|
||||
apiKey,
|
||||
prompt: prompt.prompt,
|
||||
siteId: site.id,
|
||||
builder: detectBuilder(site),
|
||||
});
|
||||
grokLive = {
|
||||
id: prompt.id,
|
||||
passed: scoreGrokResponse({ site, assert: prompt.assert, grok }).passed,
|
||||
};
|
||||
} catch (error) {
|
||||
grokLive = { id: prompt.id, passed: false, error: error instanceof Error ? error.message : 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
fixturePassed: fixtureScores.filter((row) => row.passed).length,
|
||||
fixtureTotal: fixtureScores.length,
|
||||
grokLive: grokLive ?? { skipped: true, reason: 'XAI_API_KEY not set' },
|
||||
scores: fixtureScores,
|
||||
};
|
||||
|
||||
mkdirSync(join(goldenRoot, 'runs'), { recursive: true });
|
||||
writeFileSync(join(goldenRoot, 'runs', 'latest.json'), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (report.fixturePassed !== report.fixtureTotal) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mediaProxyTarget } from './media-proxy.ts';
|
||||
import { exportDbSubset } from './subset.ts';
|
||||
import type { SiteExport } from './types.ts';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const exportDir = join(repoRoot, 'e2e/fixtures/large-exports/synthetic-2g');
|
||||
const blobPath = join(exportDir, 'uploads/2024/library.bin');
|
||||
const targetBytes = 2 * 1024 * 1024 * 1024;
|
||||
const runs = 20;
|
||||
|
||||
function ensureBlob(): number {
|
||||
mkdirSync(dirname(blobPath), { recursive: true });
|
||||
try {
|
||||
const existing = statSync(blobPath).size;
|
||||
if (existing >= targetBytes) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// create below
|
||||
}
|
||||
const result = spawnSync('mkfile', [`${targetBytes}`, blobPath], { stdio: 'inherit' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error('mkfile failed');
|
||||
}
|
||||
return statSync(blobPath).size;
|
||||
}
|
||||
|
||||
function dump(bytes: number): SiteExport {
|
||||
const posts = Array.from({ length: 8000 }, (_, index) => ({
|
||||
ID: index + 1,
|
||||
post_title: index === 0 ? 'Home' : `Post ${index + 1}`,
|
||||
post_content: 'x'.repeat(80),
|
||||
}));
|
||||
return {
|
||||
origin: 'https://big.example',
|
||||
tables: {
|
||||
wp_posts: posts,
|
||||
wp_postmeta: posts.map((post) => ({ post_id: post.ID, meta_key: '_edit_lock', meta_value: '1' })),
|
||||
wp_options: [
|
||||
{ option_name: 'blogname', option_value: 'Big' },
|
||||
{ option_name: 'woocommerce_stripe_secret_key', option_value: 'sk' },
|
||||
],
|
||||
wp_wc_orders: Array.from({ length: 20000 }, (_, index) => ({ id: index, total: '10.00' })),
|
||||
},
|
||||
uploads: [{ path: '/wp-content/uploads/2024/library.bin', bytes }],
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
const bytes = ensureBlob();
|
||||
const site = dump(bytes);
|
||||
const samples: number[] = [];
|
||||
|
||||
for (let i = 0; i < runs; i += 1) {
|
||||
const start = performance.now();
|
||||
const subset = exportDbSubset(site, { playbook: 'content', postIds: [1] });
|
||||
const proxy = mediaProxyTarget(site, '/wp-content/uploads/2024/library.bin');
|
||||
if (subset.tables.includes('wp_wc_orders')) {
|
||||
throw new Error('subset leaked orders');
|
||||
}
|
||||
if (!proxy.startsWith('https://big.example/')) {
|
||||
throw new Error('proxy missed origin');
|
||||
}
|
||||
samples.push(performance.now() - start);
|
||||
}
|
||||
|
||||
const copyDest = `${blobPath}.copy`;
|
||||
const copyStart = performance.now();
|
||||
const copy = spawnSync('dd', [`if=${blobPath}`, `of=${copyDest}`, 'bs=8m'], { stdio: 'inherit' });
|
||||
const naiveCopyMs = performance.now() - copyStart;
|
||||
if (copy.status !== 0) {
|
||||
throw new Error('naive copy failed');
|
||||
}
|
||||
|
||||
const report = {
|
||||
blobBytes: bytes,
|
||||
runs,
|
||||
subsetProxyMs: samples,
|
||||
p50Ms: percentile(samples, 50),
|
||||
p95Ms: percentile(samples, 95),
|
||||
uploadBytesCopied: 0,
|
||||
naiveCopyMs,
|
||||
targetPageBudgetMs: 60_000,
|
||||
decision: percentile(samples, 95) <= 60_000 ? 'slice holds' : 'slice is wrong',
|
||||
};
|
||||
|
||||
const out = join(dirname(fileURLToPath(import.meta.url)), '../runs/mirror-timing.json');
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { applyTool } from './apply-tool.ts';
|
||||
import { checkAssertion } from './check-assertion.ts';
|
||||
import type { GoldenAssert, GrokResponse, SiteFixture, ToolCall, ToolName } from './types.ts';
|
||||
|
||||
const tools = new Set<ToolName>(['edit_heading', 'edit_text', 'update_option']);
|
||||
|
||||
function isToolName(name: string): name is ToolName {
|
||||
return tools.has(name as ToolName);
|
||||
}
|
||||
|
||||
export function parseGrokToolCalls(grok: GrokResponse): ToolCall[] {
|
||||
const calls = grok.choices[0]?.message.tool_calls ?? [];
|
||||
return calls.map((call) => {
|
||||
if (!isToolName(call.function.name)) {
|
||||
throw new Error(`unknown tool: ${call.function.name}`);
|
||||
}
|
||||
return {
|
||||
name: call.function.name,
|
||||
arguments: JSON.parse(call.function.arguments) as Record<string, string>,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function scoreGrokResponse(input: {
|
||||
site: SiteFixture;
|
||||
assert: GoldenAssert;
|
||||
grok: GrokResponse;
|
||||
}): { passed: boolean } {
|
||||
const next = parseGrokToolCalls(input.grok).reduce(applyTool, input.site);
|
||||
return { passed: checkAssertion(next, input.assert).ok };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SiteExport, SubsetRequest, SubsetResult } from './types.ts';
|
||||
|
||||
const contentTables = new Set(['wp_posts', 'wp_postmeta', 'wp_options']);
|
||||
const secret = /(_key|_secret|smtp_pass)$/;
|
||||
|
||||
export function exportDbSubset(dump: SiteExport, request: SubsetRequest): SubsetResult {
|
||||
const tables = Object.keys(dump.tables).filter((name) => contentTables.has(name));
|
||||
const options = (dump.tables.wp_options ?? [])
|
||||
.map((row) => String(row.option_name ?? ''))
|
||||
.filter((name) => name !== '' && !secret.test(name));
|
||||
|
||||
if (request.playbook === 'content') {
|
||||
return { tables, options };
|
||||
}
|
||||
return { tables, options };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export type Builder = 'elementor' | 'beaver' | 'divi' | 'gutenberg' | 'classic';
|
||||
|
||||
export type PluginRef = {
|
||||
slug: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type PostFixture = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
meta: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SiteFixture = {
|
||||
id: string;
|
||||
theme: string;
|
||||
plugins: PluginRef[];
|
||||
wordpressVersion: string;
|
||||
phpVersion: string;
|
||||
options: Record<string, string>;
|
||||
posts: PostFixture[];
|
||||
uploads: { path: string; bytes: number }[];
|
||||
};
|
||||
|
||||
export type PreviewTextAssert = {
|
||||
type: 'preview_text';
|
||||
page: string;
|
||||
contains: string;
|
||||
};
|
||||
|
||||
export type OptionAssert = {
|
||||
type: 'option';
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ScreenshotAssert = {
|
||||
type: 'screenshot';
|
||||
page: string;
|
||||
contains: string;
|
||||
};
|
||||
|
||||
export type GoldenAssert = PreviewTextAssert | OptionAssert | ScreenshotAssert;
|
||||
|
||||
export type GoldenPrompt = {
|
||||
id: string;
|
||||
site: string;
|
||||
prompt: string;
|
||||
assert: GoldenAssert;
|
||||
};
|
||||
|
||||
export type ToolName = 'edit_heading' | 'edit_text' | 'update_option';
|
||||
|
||||
export type ToolCall = {
|
||||
name: ToolName;
|
||||
arguments: Record<string, string>;
|
||||
};
|
||||
|
||||
export type GrokResponse = {
|
||||
choices: Array<{
|
||||
message: {
|
||||
tool_calls?: Array<{
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SiteExport = {
|
||||
origin: string;
|
||||
tables: Record<string, Array<Record<string, string | number>>>;
|
||||
uploads: { path: string; bytes: number }[];
|
||||
};
|
||||
|
||||
export type SubsetRequest = {
|
||||
playbook: 'content' | 'design' | 'plugin';
|
||||
postIds: number[];
|
||||
};
|
||||
|
||||
export type SubsetResult = {
|
||||
tables: string[];
|
||||
options: string[];
|
||||
};
|
||||
Reference in New Issue
Block a user