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:
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideGc } from '../../src/sandbox/gc.ts';
|
||||
|
||||
const opts = { idleMs: 15 * 60 * 1000, hardMs: 24 * 60 * 60 * 1000 };
|
||||
const now = 1_000_000;
|
||||
|
||||
describe('decideGc', () => {
|
||||
it('pauses a running sandbox after the idle threshold', () => {
|
||||
expect(decideGc({ status: 'running', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('pause');
|
||||
});
|
||||
|
||||
it('keeps an active running sandbox', () => {
|
||||
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: 0 }, now, opts)).toBe('keep');
|
||||
});
|
||||
|
||||
it('destroys any sandbox past the hard timeout regardless of activity', () => {
|
||||
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: now - opts.hardMs - 1 }, now, opts)).toBe(
|
||||
'destroy',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not re-pause an already paused sandbox', () => {
|
||||
expect(decideGc({ status: 'paused', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('keep');
|
||||
});
|
||||
|
||||
it('keeps a destroyed sandbox', () => {
|
||||
expect(decideGc({ status: 'destroyed', lastActiveAt: 0, createdAt: 0 }, now, opts)).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { diffManifest } from '../../src/sandbox/manifest.ts';
|
||||
|
||||
describe('diffManifest', () => {
|
||||
it('detects added, changed, and removed paths', () => {
|
||||
const before = {
|
||||
'/theme/style.css': 'a1',
|
||||
'/theme/theme.json': 'b2',
|
||||
'/theme/old.css': 'c3',
|
||||
};
|
||||
const after = {
|
||||
'/theme/style.css': 'a1',
|
||||
'/theme/theme.json': 'b2-new',
|
||||
'/theme/new.css': 'd4',
|
||||
};
|
||||
|
||||
expect(diffManifest(before, after)).toEqual({
|
||||
added: ['/theme/new.css'],
|
||||
changed: ['/theme/theme.json'],
|
||||
removed: ['/theme/old.css'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports empty arrays when nothing changed', () => {
|
||||
const manifest = { '/a.css': 'x' };
|
||||
expect(diffManifest(manifest, { ...manifest })).toEqual({ added: [], changed: [], removed: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mediaProxyTarget, resolveMediaPath, stageReplacement } from '../../src/sandbox/media-proxy.ts';
|
||||
|
||||
describe('media proxy', () => {
|
||||
it('proxies an upload to the origin', () => {
|
||||
expect(mediaProxyTarget('https://example.com', '/wp-content/uploads/2024/hero.jpg')).toBe(
|
||||
'https://example.com/wp-content/uploads/2024/hero.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a staged replacement locally instead of proxying', () => {
|
||||
const staged = new Set(['/wp-content/uploads/2024/hero.jpg']);
|
||||
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/hero.jpg', staged)).toBe(
|
||||
'/wp-content/uploads/2024/hero.jpg',
|
||||
);
|
||||
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/other.jpg', staged)).toBe(
|
||||
'https://example.com/wp-content/uploads/2024/other.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a file only when it is replaced', () => {
|
||||
expect(stageReplacement('https://example.com', '/wp-content/uploads/2024/hero.jpg', 12)).toEqual({
|
||||
copiedPaths: ['/wp-content/uploads/2024/hero.jpg'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { exportDbSubset } from '../../src/sandbox/subset.ts';
|
||||
import type { SiteExport } from '../../src/sandbox/types.ts';
|
||||
|
||||
const dump = (): SiteExport => ({
|
||||
origin: 'https://example.com',
|
||||
tables: {
|
||||
wp_posts: [{ ID: 1, post_title: 'Home' }],
|
||||
wp_postmeta: [{ post_id: 1, meta_key: '_edit_lock', meta_value: '1' }],
|
||||
wp_options: [
|
||||
{ option_name: 'blogname', option_value: 'Biz' },
|
||||
{ option_name: 'woocommerce_stripe_secret_key', option_value: 'sk_live_xxx' },
|
||||
{ option_name: 'smtp_pass', option_value: 'secret' },
|
||||
],
|
||||
wp_terms: [{ term_id: 1, name: 'Menu' }],
|
||||
wp_term_taxonomy: [{ term_taxonomy_id: 1 }],
|
||||
wp_term_relationships: [{ object_id: 1 }],
|
||||
wp_wc_orders: [{ id: 99, total: '40.00' }],
|
||||
wp_comments: [{ comment_ID: 1, comment_content: 'hi' }],
|
||||
},
|
||||
uploads: [],
|
||||
});
|
||||
|
||||
describe('exportDbSubset', () => {
|
||||
it('keeps posts, postmeta, and options for a content playbook', () => {
|
||||
expect(exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables).toEqual(
|
||||
expect.arrayContaining(['wp_posts', 'wp_postmeta', 'wp_options']),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops Woo orders and comments from a content-edit slice', () => {
|
||||
const tables = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables;
|
||||
expect(tables).not.toContain('wp_wc_orders');
|
||||
expect(tables).not.toContain('wp_comments');
|
||||
});
|
||||
|
||||
it('adds taxonomy tables for a design playbook', () => {
|
||||
const tables = exportDbSubset(dump(), { playbook: 'design', postIds: [1] }).tables;
|
||||
expect(tables).toEqual(expect.arrayContaining(['wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']));
|
||||
});
|
||||
|
||||
it('limits a plugin playbook to options only', () => {
|
||||
expect(exportDbSubset(dump(), { playbook: 'plugin', postIds: [1] }).tables).toEqual(['wp_options']);
|
||||
});
|
||||
|
||||
it('redacts option names ending in _key, _secret, or smtp_pass', () => {
|
||||
const options = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).options;
|
||||
expect(options).toContain('blogname');
|
||||
expect(options.some((name) => /(_key|_secret|smtp_pass)$/.test(name))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
|
||||
import type { DockerClient } from '../../src/sandbox/docker-client.ts';
|
||||
|
||||
class FakeDocker implements DockerClient {
|
||||
created: string[] = [];
|
||||
destroyed: string[] = [];
|
||||
|
||||
async createSandbox(image: string) {
|
||||
this.created.push(image);
|
||||
return { id: 'sb-1', status: 'running' as const };
|
||||
}
|
||||
|
||||
async destroySandbox(id: string) {
|
||||
this.destroyed.push(id);
|
||||
}
|
||||
|
||||
async status(id: string) {
|
||||
return this.destroyed.includes(id) ? undefined : { id, status: 'running' as const };
|
||||
}
|
||||
}
|
||||
|
||||
describe('SandboxManager', () => {
|
||||
it('spins up a sandbox and returns a preview URL', async () => {
|
||||
const docker = new FakeDocker();
|
||||
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||
|
||||
const result = await manager.start();
|
||||
expect(result).toEqual({ sandboxId: 'sb-1', previewUrl: 'https://preview.wursor.dev/sb-1' });
|
||||
expect(docker.created).toEqual(['wursor-base:latest']);
|
||||
});
|
||||
|
||||
it('honors an explicit image override', async () => {
|
||||
const docker = new FakeDocker();
|
||||
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||
|
||||
await manager.start('wursor-base:canary');
|
||||
expect(docker.created).toEqual(['wursor-base:canary']);
|
||||
});
|
||||
|
||||
it('destroys a sandbox', async () => {
|
||||
const docker = new FakeDocker();
|
||||
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
|
||||
|
||||
await manager.destroy('sb-1');
|
||||
expect(docker.destroyed).toEqual(['sb-1']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user