From a037b882e90946837bc813edd03e3d8d89b83439 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Sat, 15 Aug 2026 20:01:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=201=20=E2=80=94=20Playwright=20e?= =?UTF-8?q?2e=20+=20sandbox=20orchestration=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/__tests__/sandbox/gc.test.ts | 29 +++++++++++ api/__tests__/sandbox/manifest.test.ts | 28 ++++++++++ api/__tests__/sandbox/media-proxy.test.ts | 26 ++++++++++ api/__tests__/sandbox/subset.test.ts | 51 +++++++++++++++++++ .../services/sandbox-manager.test.ts | 48 +++++++++++++++++ api/src/app.ts | 1 + api/src/sandbox/docker-client.ts | 10 ++++ api/src/sandbox/gc.ts | 27 ++++++++++ api/src/sandbox/manifest.ts | 14 +++++ api/src/sandbox/media-proxy.ts | 11 ++++ api/src/sandbox/subset.ts | 19 +++++++ api/src/sandbox/types.ts | 17 +++++++ api/src/services/sandbox-manager.ts | 22 ++++++++ e2e/package.json | 1 + e2e/playwright.config.ts | 22 ++++++++ e2e/tests/chat-flow.test.ts | 12 +++++ pnpm-lock.yaml | 38 ++++++++++++++ 17 files changed, 376 insertions(+) create mode 100644 api/__tests__/sandbox/gc.test.ts create mode 100644 api/__tests__/sandbox/manifest.test.ts create mode 100644 api/__tests__/sandbox/media-proxy.test.ts create mode 100644 api/__tests__/sandbox/subset.test.ts create mode 100644 api/__tests__/services/sandbox-manager.test.ts create mode 100644 api/src/sandbox/docker-client.ts create mode 100644 api/src/sandbox/gc.ts create mode 100644 api/src/sandbox/manifest.ts create mode 100644 api/src/sandbox/media-proxy.ts create mode 100644 api/src/sandbox/subset.ts create mode 100644 api/src/sandbox/types.ts create mode 100644 api/src/services/sandbox-manager.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/chat-flow.test.ts diff --git a/api/__tests__/sandbox/gc.test.ts b/api/__tests__/sandbox/gc.test.ts new file mode 100644 index 0000000..1b01e3d --- /dev/null +++ b/api/__tests__/sandbox/gc.test.ts @@ -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'); + }); +}); diff --git a/api/__tests__/sandbox/manifest.test.ts b/api/__tests__/sandbox/manifest.test.ts new file mode 100644 index 0000000..0ada802 --- /dev/null +++ b/api/__tests__/sandbox/manifest.test.ts @@ -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: [] }); + }); +}); diff --git a/api/__tests__/sandbox/media-proxy.test.ts b/api/__tests__/sandbox/media-proxy.test.ts new file mode 100644 index 0000000..69e9175 --- /dev/null +++ b/api/__tests__/sandbox/media-proxy.test.ts @@ -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'], + }); + }); +}); diff --git a/api/__tests__/sandbox/subset.test.ts b/api/__tests__/sandbox/subset.test.ts new file mode 100644 index 0000000..ff2bc31 --- /dev/null +++ b/api/__tests__/sandbox/subset.test.ts @@ -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); + }); +}); diff --git a/api/__tests__/services/sandbox-manager.test.ts b/api/__tests__/services/sandbox-manager.test.ts new file mode 100644 index 0000000..b721f96 --- /dev/null +++ b/api/__tests__/services/sandbox-manager.test.ts @@ -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']); + }); +}); diff --git a/api/src/app.ts b/api/src/app.ts index 19ef68d..5b7aada 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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; } diff --git a/api/src/sandbox/docker-client.ts b/api/src/sandbox/docker-client.ts new file mode 100644 index 0000000..0ed82ca --- /dev/null +++ b/api/src/sandbox/docker-client.ts @@ -0,0 +1,10 @@ +export type SandboxInfo = { + id: string; + status: 'running' | 'destroyed'; +}; + +export interface DockerClient { + createSandbox(image: string): Promise; + destroySandbox(id: string): Promise; + status(id: string): Promise; +} diff --git a/api/src/sandbox/gc.ts b/api/src/sandbox/gc.ts new file mode 100644 index 0000000..b5a6d7c --- /dev/null +++ b/api/src/sandbox/gc.ts @@ -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'; +} diff --git a/api/src/sandbox/manifest.ts b/api/src/sandbox/manifest.ts new file mode 100644 index 0000000..ab202ec --- /dev/null +++ b/api/src/sandbox/manifest.ts @@ -0,0 +1,14 @@ +export type Manifest = Record; + +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 }; +} diff --git a/api/src/sandbox/media-proxy.ts b/api/src/sandbox/media-proxy.ts new file mode 100644 index 0000000..1423be3 --- /dev/null +++ b/api/src/sandbox/media-proxy.ts @@ -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 { + return staged.has(path) ? path : mediaProxyTarget(origin, path); +} + +export function stageReplacement(_origin: string, path: string, _bytes: number): { copiedPaths: string[] } { + return { copiedPaths: [path] }; +} diff --git a/api/src/sandbox/subset.ts b/api/src/sandbox/subset.ts new file mode 100644 index 0000000..1321bd1 --- /dev/null +++ b/api/src/sandbox/subset.ts @@ -0,0 +1,19 @@ +import type { Playbook, SiteExport, SubsetRequest, SubsetResult } from './types.ts'; + +const PLAYBOOK_TABLES: Record> = { + 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 }; +} diff --git a/api/src/sandbox/types.ts b/api/src/sandbox/types.ts new file mode 100644 index 0000000..ebc9ef5 --- /dev/null +++ b/api/src/sandbox/types.ts @@ -0,0 +1,17 @@ +export type Playbook = 'content' | 'design' | 'plugin'; + +export type SiteExport = { + origin: string; + tables: Record>>; + uploads: { path: string; bytes: number }[]; +}; + +export type SubsetRequest = { + playbook: Playbook; + postIds: number[]; +}; + +export type SubsetResult = { + tables: string[]; + options: string[]; +}; diff --git a/api/src/services/sandbox-manager.ts b/api/src/services/sandbox-manager.ts new file mode 100644 index 0000000..dac420f --- /dev/null +++ b/api/src/services/sandbox-manager.ts @@ -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 { + await this.docker.destroySandbox(id); + } +} diff --git a/e2e/package.json b/e2e/package.json index 6ea7497..5ccc8d7 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -8,6 +8,7 @@ "mirror:time": "tsx golden/src/run-mirror-timing.ts" }, "devDependencies": { + "@playwright/test": "^1.62.1", "tsx": "^4.20.3", "typescript": "^5.9.2", "vitest": "^3.2.4" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..5b7aac7 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + retries: 0, + use: { + baseURL: 'http://localhost:5173', + }, + webServer: [ + { + command: 'pnpm --filter @wursor/api start', + url: 'http://localhost:3000/health', + reuseExistingServer: !process.env.CI, + }, + { + command: 'pnpm --filter @wursor/web dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + ], +}); diff --git a/e2e/tests/chat-flow.test.ts b/e2e/tests/chat-flow.test.ts new file mode 100644 index 0000000..7b0c98d --- /dev/null +++ b/e2e/tests/chat-flow.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; + +test('user signs up and sees the chat interface', async ({ page }) => { + await page.goto('/'); + + await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`); + await page.getByLabel('Password').fill('password123'); + await page.getByRole('button', { name: 'Sign up' }).click(); + + await expect(page.locator('.wursor-chat-input')).toBeVisible(); + await expect(page.locator('.wursor-welcome')).toContainText('Describe what you want'); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb0c5a6..834dd45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: e2e: devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 tsx: specifier: ^4.20.3 version: 4.23.12 @@ -343,6 +346,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm64@1.2.4': resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -833,6 +841,11 @@ packages: resolution: {integrity: sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==} engines: {node: '>=20'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1011,6 +1024,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -1515,6 +1538,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -1915,6 +1942,9 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2073,6 +2103,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.26: dependencies: nanoid: 3.3.18