diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7c54c74b75..4f2d92d052 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -597,6 +597,57 @@ jobs: - name: Test coglet-python bindings run: uvx nox -s coglet -p ${{ matrix.python-version }} + test-playground-e2e: + name: Playground Playwright integration tests (Chromium) + needs: [playground-changes, playground-check, build-cog, build-sdk, build-rust] + if: needs.playground-changes.outputs.changed == 'true' + runs-on: ubuntu-latest-16-cores + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Login to Docker Hub + uses: docker/login-action@v4 + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + with: + registry: index.docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + - name: Install cog binary + run: | + cp dist/cog ./cog + chmod +x ./cog + - uses: jdx/mise-action@v4 + with: + version: 2026.4.27 + cache_key_prefix: mise-ci-${{ github.job }} + - name: Install playground dependencies and Chromium + run: | + mise run playground:install + pnpm --dir playground exec playwright install --with-deps chromium + - name: Run browser integration tests + env: + BUILDKIT_PROGRESS: quiet + COG_BINARY: ${{ github.workspace }}/cog + COG_REGISTRY_HOST: ghcr.io/replicate/cog + COG_SDK_WHEEL: ${{ github.workspace }}/dist + COGLET_WHEEL: ${{ github.workspace }}/dist + run: pnpm --dir playground run test:e2e + - name: Upload browser test report + if: failure() + uses: actions/upload-artifact@v6 + with: + name: PlaygroundPlaywrightReport + path: | + playground/playwright-report + playground/test-results + # Compute integration test shards dynamically. # Slow tests (tagged with [short] skip) are distributed round-robin first, # then remaining tests fill in. This ensures slow tests don't pile up on one runner. @@ -783,6 +834,7 @@ jobs: - test-rust - test-python - test-coglet-python + - test-playground-e2e - integration-shards - test-integration if: always() @@ -814,6 +866,7 @@ jobs: echo " test-rust: ${{ needs.test-rust.result }}" echo " test-python: ${{ needs.test-python.result }}" echo " test-coglet-python: ${{ needs.test-coglet-python.result }}" + echo " test-playground-e2e: ${{ needs.test-playground-e2e.result }}" echo " integration-shards: ${{ needs.integration-shards.result }}" echo " test-integration: ${{ needs.test-integration.result }}" @@ -852,9 +905,11 @@ jobs: if [ "${{ needs.playground-changes.outputs.changed }}" = "true" ]; then check_success playground-check "${{ needs.playground-check.result }}" + check_success test-playground-e2e "${{ needs.test-playground-e2e.result }}" else - if [ "${{ needs.playground-check.result }}" != "skipped" ]; then - echo "::error::Playground check should be skipped when playground sources are unchanged" + if [ "${{ needs.playground-check.result }}" != "skipped" ] || \ + [ "${{ needs.test-playground-e2e.result }}" != "skipped" ]; then + echo "::error::Playground jobs should be skipped when playground sources are unchanged" FAILED=true fi fi diff --git a/.gitignore b/.gitignore index a80fc21980..35ee93229c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ target # Playground frontend /playground/node_modules/ /playground/coverage/ +/playground/playwright-report/ +/playground/test-results/ /pkg/cli/playground/ diff --git a/mise.toml b/mise.toml index 0fa43837f1..83739625fe 100644 --- a/mise.toml +++ b/mise.toml @@ -339,6 +339,21 @@ depends = ["playground:install"] dir = "playground" run = "pnpm run test:coverage" +[tasks."test:playground:e2e"] +description = "Run playground browser tests against a real Cog server" +depends = ["build:cog", "build:sdk", "build:coglet:wheel:linux-x64", "playground:install"] +dir = "playground" +run = """ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd .. && pwd)" +BINARY="${COG_BINARY:-$(ls "$ROOT"/dist/go/*/cog | head -1)}" +COG_BINARY="$BINARY" \ +COG_SDK_WHEEL="${COG_SDK_WHEEL:-$ROOT/dist}" \ +COGLET_WHEEL="${COGLET_WHEEL:-$ROOT/dist}" \ +pnpm run test:e2e +""" + [tasks."test:python:all"] description = "Run Python SDK tests on all supported Python versions" depends = ["build:coglet:wheel"] diff --git a/playground/.gitignore b/playground/.gitignore index 379f9cb5ab..a55b3fb16e 100644 --- a/playground/.gitignore +++ b/playground/.gitignore @@ -1,3 +1,5 @@ **/node_modules/ **/coverage/ +**/playwright-report/ +**/test-results/ *.log diff --git a/playground/.oxfmtrc.json b/playground/.oxfmtrc.json index 46289a2f35..600e409925 100644 --- a/playground/.oxfmtrc.json +++ b/playground/.oxfmtrc.json @@ -1,5 +1,5 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "printWidth": 100, - "ignorePatterns": ["coverage"] + "ignorePatterns": ["coverage", "playwright-report", "test-results"] } diff --git a/playground/.oxlintrc.json b/playground/.oxlintrc.json index 99af3a874c..ee4fa674b3 100644 --- a/playground/.oxlintrc.json +++ b/playground/.oxlintrc.json @@ -7,5 +7,5 @@ "env": { "browser": true }, - "ignorePatterns": ["coverage"] + "ignorePatterns": ["coverage", "playwright-report", "test-results"] } diff --git a/playground/e2e/fixture/cog.yaml b/playground/e2e/fixture/cog.yaml new file mode 100644 index 0000000000..e19c4aea46 --- /dev/null +++ b/playground/e2e/fixture/cog.yaml @@ -0,0 +1,3 @@ +build: + python_version: "3.12" +run: "run.py:Runner" diff --git a/playground/e2e/fixture/run.py b/playground/e2e/fixture/run.py new file mode 100644 index 0000000000..c4dd80f48b --- /dev/null +++ b/playground/e2e/fixture/run.py @@ -0,0 +1,14 @@ +import time + +from cog import BaseRunner, ConcatenateIterator, Input, streaming + + +class Runner(BaseRunner): + @streaming + def run( + self, text: str = Input(description="Text to prefix with 'hello '") + ) -> ConcatenateIterator[str]: + yield "hello " + if text == "slow": + time.sleep(2) + yield text diff --git a/playground/e2e/playground.spec.ts b/playground/e2e/playground.spec.ts new file mode 100644 index 0000000000..44b10c7ea9 --- /dev/null +++ b/playground/e2e/playground.spec.ts @@ -0,0 +1,201 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFile } from "node:fs/promises"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Cog Playground" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "text" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("status").filter({ hasText: "ready" })).toBeVisible({ + timeout: 30_000, + }); +}); + +test("keeps Form and CodeMirror JSON input synchronized", async ({ page }) => { + const run = page.getByRole("button", { name: "Run" }); + await expect(run).toBeDisabled(); + await page.getByRole("textbox", { name: "text" }).fill("from form"); + await page.getByRole("tab", { name: "JSON" }).click(); + + const editor = page.getByRole("textbox", { name: "Prediction input JSON" }); + await expect(editor).toContainText('"text": "from form"'); + await editor.click(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Copy" })).toBeFocused(); + await replaceEditor(page, "Prediction input JSON", '{"text":"from json"}'); + await page.getByRole("button", { name: "Format" }).click(); + await expect(editor).toContainText('"text": "from json"'); + await expect(run).toBeEnabled(); + + await replaceEditor(page, "Prediction input JSON", "{"); + await expect(run).toBeDisabled(); + await page.getByRole("tab", { name: "Form" }).click(); + await expect(page.getByRole("textbox", { name: "text" })).toHaveValue("from json"); + + await page.getByRole("tab", { name: "JSON" }).click(); + await expect(page.getByRole("textbox", { name: "Prediction input JSON" })).toContainText( + '"text": "from json"', + ); + await expect(run).toBeEnabled(); +}); + +test("runs a synchronous prediction and inspects its response", async ({ page }) => { + await page.getByRole("textbox", { name: "text" }).fill("sync"); + await page.getByRole("tab", { name: "Sync", exact: true }).click(); + await page.getByRole("button", { name: "Run" }).click(); + + await expectPredictionStatus(page, "succeeded"); + await expect(page.getByRole("log")).toContainText("hello sync"); + + await page.getByRole("tab", { name: "Response", exact: true }).click(); + const response = page.getByRole("textbox", { name: "Prediction response" }); + await expect(response).toHaveAttribute("aria-readonly", "true"); + await expect(response).toHaveAttribute("contenteditable", "false"); + await expect(response).toContainText('"status": "succeeded"'); + await expect(page.locator(".response-editor .cm-content span").first()).toBeVisible(); + + await page.getByRole("tab", { name: "Timeline" }).click(); + await expect(page.getByText(/POST \/predictions/)).toBeVisible(); + await page.getByRole("tab", { name: "Request" }).click(); + await expect(page.getByText("Total duration")).toBeVisible(); + await expect(page.getByText("200", { exact: true })).toBeVisible(); +}); + +test("shows progressive streaming output before completion", async ({ page }) => { + await page.getByRole("textbox", { name: "text" }).fill("slow"); + await page.getByRole("tab", { name: "Stream", exact: true }).click(); + await page.getByRole("button", { name: "Run" }).click(); + + const output = page.getByRole("log"); + await expect(output).toHaveAttribute("aria-busy", "true"); + await expect( + page.locator("#output-panel").getByRole("status").filter({ hasText: "processing" }), + ).toBeVisible(); + await expect(output).toContainText("hello", { timeout: 30_000 }); + await expect(output).not.toContainText("slow"); + + await expectPredictionStatus(page, "succeeded"); + await expect(output).toHaveAttribute("aria-busy", "false"); + await expect(output).toContainText("hello slow"); +}); + +test("stops a running prediction", async ({ page }) => { + await page.getByRole("textbox", { name: "text" }).fill("slow"); + await page.getByRole("textbox", { name: "Prediction ID" }).fill("playground-stop-id"); + await page.getByRole("tab", { name: "Stream", exact: true }).click(); + await page.getByRole("button", { name: "Run" }).click(); + await expect(page.getByRole("button", { name: "Stop" })).toBeEnabled(); + await expect(page.getByRole("log")).toContainText("hello", { timeout: 30_000 }); + + const cancelResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/proxy/predictions/playground-stop-id/cancel", + ); + await page.getByRole("button", { name: "Stop" }).click(); + expect((await cancelResponse).ok()).toBe(true); + + await expectPredictionStatus(page, "canceled"); + await expect(page.getByRole("button", { name: "Run" })).toBeEnabled(); + await expect(page.getByRole("status").filter({ hasText: "ready" })).toBeVisible({ + timeout: 30_000, + }); +}); + +test("configures and receives an asynchronous webhook prediction", async ({ page }) => { + await page.getByRole("textbox", { name: "text" }).fill("webhook"); + await page.getByRole("tab", { name: "Async", exact: true }).click(); + await expect(page.getByText(/Webhook: .*\/webhook\/\.\.\./)).toBeVisible(); + await page.getByRole("checkbox", { name: "output" }).click(); + await page.getByRole("checkbox", { name: "logs" }).click(); + await expect(page.getByRole("checkbox", { name: "completed" })).toHaveAttribute( + "aria-disabled", + "true", + ); + await page.getByRole("button", { name: "Run" }).click(); + + await expectPredictionStatus(page, "succeeded"); + await expect(page.getByRole("log")).toContainText("hello webhook"); + await page.getByRole("tab", { name: "Request" }).click(); + const requestBody = page.getByLabel("Request body"); + await expect(requestBody).toContainText("start"); + await expect(requestBody).toContainText("completed"); + await expect(requestBody).not.toContainText('"logs"'); +}); + +test("reconnects and downloads the loaded schema", async ({ page }) => { + const target = page.getByRole("textbox", { name: "Target" }); + const targetURL = await target.inputValue(); + await target.fill(" "); + await expect(page.getByRole("button", { name: "Connect" })).toBeDisabled(); + await target.fill(`${targetURL}/`); + const schemaResponse = page.waitForResponse( + (response) => new URL(response.url()).pathname === "/proxy/openapi.json", + ); + await target.press("Enter"); + expect((await schemaResponse).ok()).toBe(true); + await expect(page.getByRole("status").filter({ hasText: "ready" })).toBeVisible(); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Schema" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("openapi.json"); + const downloadPath = await download.path(); + if (!downloadPath) throw new Error("Schema download has no local path"); + const schema = JSON.parse(await readFile(downloadPath, "utf8")) as { openapi?: string }; + expect(schema.openapi).toMatch(/^3\./); +}); + +test("toggles the color theme", async ({ page }) => { + const root = page.locator("html"); + const initialTheme = await root.getAttribute("data-mode"); + const nextTheme = initialTheme === "dark" ? "light" : "dark"; + const toggleLabel = initialTheme === "dark" ? "Light" : "Dark"; + await page.getByRole("button", { name: toggleLabel }).click(); + await expect(root).toHaveAttribute("data-mode", nextTheme); +}); + +test("uses a custom prediction ID and resets the playground", async ({ page }) => { + await page.getByRole("textbox", { name: "text" }).fill("identified"); + await page.getByRole("textbox", { name: "Prediction ID" }).fill("playground-e2e-id"); + await page.getByRole("tab", { name: "Sync", exact: true }).click(); + const predictionRequest = page.waitForRequest( + (request) => + request.method() === "PUT" && + new URL(request.url()).pathname === "/proxy/predictions/playground-e2e-id", + ); + await page.getByRole("button", { name: "Run" }).click(); + await predictionRequest; + await expectPredictionStatus(page, "succeeded"); + await expect(page.getByRole("log")).toContainText("hello identified"); + + await page.getByRole("button", { name: "Reset" }).click(); + await expect(page.getByRole("textbox", { name: "text" })).toHaveValue(""); + await expect(page.getByRole("textbox", { name: "Prediction ID" })).toHaveValue(""); + await expect(page.getByText("Run a prediction to see its output.")).toBeVisible(); +}); + +test("fits every primary control on a narrow viewport", async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + + const widths = await page.evaluate(() => ({ + document: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + await expect(page.getByRole("button", { name: "Run" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Form" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Output" })).toBeVisible(); +}); + +async function replaceEditor(page: Page, label: string, value: string): Promise { + const editor = page.getByRole("textbox", { name: label }); + await editor.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.insertText(value); +} + +async function expectPredictionStatus(page: Page, status: string): Promise { + await expect( + page.locator("#output-panel").getByRole("status").filter({ hasText: status }), + ).toBeVisible({ timeout: 60_000 }); +} diff --git a/playground/e2e/serve.ts b/playground/e2e/serve.ts new file mode 100644 index 0000000000..ef0f7b3ee1 --- /dev/null +++ b/playground/e2e/serve.ts @@ -0,0 +1,141 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { createServer } from "node:net"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const cog = process.env.COG_BINARY; +if (!cog) throw new Error("COG_BINARY must point to the built Cog CLI"); +const cogBinary = cog; + +const fixture = fileURLToPath(new URL("./fixture/", import.meta.url)); +const modelPort = await availablePort(); +const modelURL = `http://127.0.0.1:${modelPort}`; +const playgroundPort = 8400; +const playgroundURL = `http://127.0.0.1:${playgroundPort}`; +const children: ChildProcess[] = []; +const childFailures: Promise[] = []; +let stopping = false; + +function availablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => { + if (error) reject(error); + else if (typeof address === "object" && address) resolve(address.port); + else reject(new Error("Could not allocate a model port")); + }); + }); + }); +} + +function start(name: string, args: string[], cwd: string): Promise { + const child = spawn(cogBinary, args, { + cwd, + env: { ...process.env, BUILDKIT_PROGRESS: "quiet" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (data: Buffer) => process.stdout.write(`[${name}] ${data}`)); + child.stderr?.on("data", (data: Buffer) => process.stderr.write(`[${name}] ${data}`)); + const failure = new Promise((_, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (!stopping) reject(new Error(`${name} exited unexpectedly (${code ?? signal})`)); + }); + }); + children.push(child); + childFailures.push(failure); + return failure; +} + +async function waitFor(url: string, timeout: number): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (response.ok) return; + } catch { + // The process is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +async function waitForModel(timeout: number): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { + const response = await fetch(`${modelURL}/health-check`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) { + const health = (await response.json()) as { status?: string }; + if (health.status === "READY" || health.status === "BUSY") return; + if (health.status === "SETUP_FAILED" || health.status === "DEFUNCT") { + throw new Error(`Model startup failed with status ${health.status}`); + } + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("Model startup failed")) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for ${modelURL}`); +} + +async function stop(): Promise { + if (stopping) return; + stopping = true; + try { + await fetch(`${modelURL}/shutdown`, { + method: "POST", + signal: AbortSignal.timeout(5000), + }); + } catch { + // Fall back to process signals below. + } + for (const child of children) child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 3000)); + for (const child of children) { + if (child.exitCode === null) child.kill("SIGKILL"); + } +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + void stop().finally(() => process.exit(0)); + }); +} + +try { + // Enable Cog's host-gateway mapping so webhooks reach the host on Linux CI. + const modelFailure = start( + "model", + ["serve", "--port", String(modelPort), "--upload-url", "http://unused/"], + fixture, + ); + await Promise.race([waitForModel(12 * 60_000), modelFailure]); + const playgroundFailure = start( + "playground", + [ + "playground", + "--host", + "0.0.0.0", + "--port", + String(playgroundPort), + "--target", + modelURL, + "--no-open", + ], + fixture, + ); + await Promise.race([waitFor(playgroundURL, 30_000), playgroundFailure]); + await Promise.race(childFailures); +} catch (error) { + console.error(error); + await stop(); + process.exit(1); +} diff --git a/playground/package.json b/playground/package.json index b3339e0013..2d0f9334a0 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,6 +10,7 @@ "fmt:check": "oxfmt --check", "lint": "oxlint", "lint:fix": "oxlint --fix", + "test:e2e": "playwright test", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", @@ -28,6 +29,7 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@tailwindcss/vite": "^4.1.17", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts new file mode 100644 index 0000000000..06944eb1b3 --- /dev/null +++ b/playground/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + timeout: 60_000, + use: { + baseURL: "http://127.0.0.1:8400", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: "node ./e2e/serve.ts", + url: "http://127.0.0.1:8400", + reuseExistingServer: false, + timeout: 15 * 60_000, + gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/playground/pnpm-lock.yaml b/playground/pnpm-lock.yaml index 7f617b611e..ef98cfd2d7 100644 --- a/playground/pnpm-lock.yaml +++ b/playground/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: ^19.2.4 version: 19.2.7(react@19.2.7) devDependencies: + '@playwright/test': + specifier: ^1.55.0 + version: 1.61.1 '@tailwindcss/vite': specifier: ^4.1.17 version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0)) @@ -613,6 +616,11 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1140,6 +1148,11 @@ packages: react-dom: optional: true + 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} @@ -1433,6 +1446,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -2214,6 +2237,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -2672,6 +2699,9 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2970,6 +3000,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.16: dependencies: nanoid: 3.3.15 diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 9680b5e1a5..dc467d5683 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -20,5 +20,12 @@ "jsx": "react-jsx", "types": ["vitest/globals", "@testing-library/jest-dom"] }, - "include": ["src", "scripts/**/*.ts", "vite.config.ts", "vitest.config.ts"] + "include": [ + "src", + "e2e/**/*.ts", + "scripts/**/*.ts", + "playwright.config.ts", + "vite.config.ts", + "vitest.config.ts" + ] }