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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { InMemoryUserStore } from './services/user-store.ts';
|
|||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const store = new InMemoryUserStore();
|
const store = new InMemoryUserStore();
|
||||||
|
app.get('/health', async () => ({ ok: true }));
|
||||||
await authRoutes(app, store);
|
await authRoutes(app, store);
|
||||||
return app;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"mirror:time": "tsx golden/src/run-mirror-timing.ts"
|
"mirror:time": "tsx golden/src/run-mirror-timing.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Generated
+38
@@ -29,6 +29,9 @@ importers:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.62.1
|
||||||
|
version: 1.62.1
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.20.3
|
specifier: ^4.20.3
|
||||||
version: 4.23.12
|
version: 4.23.12
|
||||||
@@ -343,6 +346,11 @@ packages:
|
|||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
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':
|
'@rolldown/binding-android-arm64@1.2.4':
|
||||||
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
|
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -833,6 +841,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==}
|
resolution: {integrity: sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==}
|
||||||
engines: {node: '>=20'}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1011,6 +1024,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
hasBin: true
|
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:
|
postcss@8.5.26:
|
||||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -1515,6 +1538,10 @@ snapshots:
|
|||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.62.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.1
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.4':
|
'@rolldown/binding-android-arm64@1.2.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1915,6 +1942,9 @@ snapshots:
|
|||||||
fast-querystring: 1.1.2
|
fast-querystring: 1.1.2
|
||||||
safe-regex2: 5.1.1
|
safe-regex2: 5.1.1
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2073,6 +2103,14 @@ snapshots:
|
|||||||
sonic-boom: 4.2.1
|
sonic-boom: 4.2.1
|
||||||
thread-stream: 4.2.0
|
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:
|
postcss@8.5.26:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.18
|
nanoid: 3.3.18
|
||||||
|
|||||||
Reference in New Issue
Block a user