feat: Sprint 1 — Playwright e2e + sandbox orchestration (TDD)
Mirror to GitHub / mirror (push) Canceled after 0s
Mirror to GitHub / mirror (push) Canceled after 0s
- e2e: Playwright chat-flow (sign up -> chat) with api/web webServer harness - api: sandbox services subset/media-proxy/manifest/gc + DockerClient contract - api: SandboxManager orchestrator (fake-Docker tested); /health route - 53 unit tests green; Playwright e2e green
This commit is contained in:
@@ -5,6 +5,7 @@ import { InMemoryUserStore } from './services/user-store.ts';
|
||||
export async function buildApp() {
|
||||
const app = Fastify();
|
||||
const store = new InMemoryUserStore();
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
await authRoutes(app, store);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type SandboxInfo = {
|
||||
id: string;
|
||||
status: 'running' | 'destroyed';
|
||||
};
|
||||
|
||||
export interface DockerClient {
|
||||
createSandbox(image: string): Promise<SandboxInfo>;
|
||||
destroySandbox(id: string): Promise<void>;
|
||||
status(id: string): Promise<SandboxInfo | undefined>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type SandboxStatus = 'running' | 'paused' | 'destroyed';
|
||||
|
||||
export type SandboxState = {
|
||||
status: SandboxStatus;
|
||||
lastActiveAt: number;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type GcAction = 'keep' | 'pause' | 'destroy';
|
||||
|
||||
export type GcOptions = {
|
||||
idleMs: number;
|
||||
hardMs: number;
|
||||
};
|
||||
|
||||
export function decideGc(state: SandboxState, now: number, opts: GcOptions): GcAction {
|
||||
if (state.status === 'destroyed') {
|
||||
return 'keep';
|
||||
}
|
||||
if (now - state.createdAt >= opts.hardMs) {
|
||||
return 'destroy';
|
||||
}
|
||||
if (state.status === 'running' && now - state.lastActiveAt >= opts.idleMs) {
|
||||
return 'pause';
|
||||
}
|
||||
return 'keep';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type Manifest = Record<string, string>;
|
||||
|
||||
export type ManifestDiff = {
|
||||
added: string[];
|
||||
changed: string[];
|
||||
removed: string[];
|
||||
};
|
||||
|
||||
export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
|
||||
const added = Object.keys(after).filter((path) => !(path in before));
|
||||
const removed = Object.keys(before).filter((path) => !(path in after));
|
||||
const changed = Object.keys(after).filter((path) => path in before && before[path] !== after[path]);
|
||||
return { added, changed, removed };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function mediaProxyTarget(origin: string, path: string): string {
|
||||
return `${origin}${path}`;
|
||||
}
|
||||
|
||||
export function resolveMediaPath(origin: string, path: string, staged: ReadonlySet<string>): string {
|
||||
return staged.has(path) ? path : mediaProxyTarget(origin, path);
|
||||
}
|
||||
|
||||
export function stageReplacement(_origin: string, path: string, _bytes: number): { copiedPaths: string[] } {
|
||||
return { copiedPaths: [path] };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Playbook, SiteExport, SubsetRequest, SubsetResult } from './types.ts';
|
||||
|
||||
const PLAYBOOK_TABLES: Record<Playbook, Set<string>> = {
|
||||
content: new Set(['wp_posts', 'wp_postmeta', 'wp_options']),
|
||||
design: new Set(['wp_posts', 'wp_postmeta', 'wp_options', 'wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']),
|
||||
plugin: new Set(['wp_options']),
|
||||
};
|
||||
|
||||
const secret = /(_key|_secret|smtp_pass)$/;
|
||||
|
||||
export function exportDbSubset(dump: SiteExport, request: SubsetRequest): SubsetResult {
|
||||
const wanted = PLAYBOOK_TABLES[request.playbook];
|
||||
const tables = Object.keys(dump.tables).filter((name) => wanted.has(name));
|
||||
const options = (dump.tables.wp_options ?? [])
|
||||
.map((row) => String(row.option_name ?? ''))
|
||||
.filter((name) => name !== '' && !secret.test(name));
|
||||
|
||||
return { tables, options };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type Playbook = 'content' | 'design' | 'plugin';
|
||||
|
||||
export type SiteExport = {
|
||||
origin: string;
|
||||
tables: Record<string, Array<Record<string, string | number>>>;
|
||||
uploads: { path: string; bytes: number }[];
|
||||
};
|
||||
|
||||
export type SubsetRequest = {
|
||||
playbook: Playbook;
|
||||
postIds: number[];
|
||||
};
|
||||
|
||||
export type SubsetResult = {
|
||||
tables: string[];
|
||||
options: string[];
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DockerClient } from '../sandbox/docker-client.ts';
|
||||
|
||||
export type SandboxManagerOptions = {
|
||||
image: string;
|
||||
previewBaseUrl: string;
|
||||
};
|
||||
|
||||
export class SandboxManager {
|
||||
constructor(
|
||||
private readonly docker: DockerClient,
|
||||
private readonly opts: SandboxManagerOptions,
|
||||
) {}
|
||||
|
||||
async start(image?: string): Promise<{ sandboxId: string; previewUrl: string }> {
|
||||
const info = await this.docker.createSandbox(image ?? this.opts.image);
|
||||
return { sandboxId: info.id, previewUrl: `${this.opts.previewBaseUrl}/${info.id}` };
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
await this.docker.destroySandbox(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user