Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -783,6 +834,7 @@ jobs:
- test-rust
- test-python
- test-coglet-python
- test-playground-e2e
- integration-shards
- test-integration
if: always()
Expand Down Expand Up @@ -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 }}"

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ target
# Playground frontend
/playground/node_modules/
/playground/coverage/
/playground/playwright-report/
/playground/test-results/
/pkg/cli/playground/
15 changes: 15 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions playground/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**/node_modules/
**/coverage/
**/playwright-report/
**/test-results/
*.log
2 changes: 1 addition & 1 deletion playground/.oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 100,
"ignorePatterns": ["coverage"]
"ignorePatterns": ["coverage", "playwright-report", "test-results"]
}
2 changes: 1 addition & 1 deletion playground/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"env": {
"browser": true
},
"ignorePatterns": ["coverage"]
"ignorePatterns": ["coverage", "playwright-report", "test-results"]
}
3 changes: 3 additions & 0 deletions playground/e2e/fixture/cog.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build:
python_version: "3.12"
run: "run.py:Runner"
14 changes: 14 additions & 0 deletions playground/e2e/fixture/run.py
Original file line number Diff line number Diff line change
@@ -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
201 changes: 201 additions & 0 deletions playground/e2e/playground.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await expect(
page.locator("#output-panel").getByRole("status").filter({ hasText: status }),
).toBeVisible({ timeout: 60_000 });
}
Loading
Loading