mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// LIVE smoke — API shape only (no model tokens). Hits the REAL sidecar's /v1/health and
|
||||
// /v1/providers to catch integration drift between the GUI's expectations and the backend's
|
||||
// responses. Skips cleanly when the backend is down, so it's safe to run anytime. No creds needed.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { BACKEND } from "./helpers";
|
||||
|
||||
async function backendUp(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/v1/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("health reports ok with the fields the GUI reads", async () => {
|
||||
test.skip(!(await backendUp()), "backend not running on :8765");
|
||||
const s = await (await fetch(`${BACKEND}/v1/health`)).json();
|
||||
expect(s.status).toBe("ok");
|
||||
// The GUI's boot reads these three off /v1/health.
|
||||
expect(s).toHaveProperty("model");
|
||||
expect(s).toHaveProperty("default_workspace");
|
||||
});
|
||||
|
||||
test("providers list has the shape the Settings pane expects", async () => {
|
||||
test.skip(!(await backendUp()), "backend not running on :8765");
|
||||
const providers = await (await fetch(`${BACKEND}/v1/providers`)).json();
|
||||
expect(Array.isArray(providers)).toBe(true);
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
// Each descriptor carries what ManageTabs renders: name/title/needs_key/fields/configured.
|
||||
for (const p of providers) {
|
||||
expect(p).toMatchObject({
|
||||
name: expect.any(String),
|
||||
title: expect.any(String),
|
||||
needs_key: expect.any(Boolean),
|
||||
configured: expect.any(Boolean),
|
||||
});
|
||||
expect(Array.isArray(p.fields)).toBe(true);
|
||||
}
|
||||
// The core providers Rohit tested should be present.
|
||||
const names = providers.map((p: any) => p.name);
|
||||
expect(names).toEqual(expect.arrayContaining(["openai", "anthropic"]));
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "fs";
|
||||
import { newestFile, scratchBaseIfReady, sendTask, startCoworkSession } from "./helpers";
|
||||
|
||||
// LIVE #1 — the approval gate. In the default "Ask for approval" mode a tool call must block on an
|
||||
// in-transcript approval card; approving it lets execution proceed. (fib.md skips this via Full
|
||||
// access.) Excluded from CI — run with `npm run e2e:live`.
|
||||
|
||||
test("live: a write blocks on an approval card, then completes once approved", async ({ page }) => {
|
||||
const scratchBase = await scratchBaseIfReady();
|
||||
test.skip(!scratchBase, "live backend not ready — start coworker-server and configure a model");
|
||||
|
||||
// Unique filename per run so the "doesn't exist before approval" check can't see a prior run's file.
|
||||
const name = `hello-${Date.now()}.txt`;
|
||||
|
||||
await startCoworkSession(page);
|
||||
// Leave the default "Ask for approval" mode — the write should gate.
|
||||
await sendTask(page, `Create a file named ${name} containing exactly the text: hello world`);
|
||||
|
||||
// The tool call blocks on an approval card, and the file does not exist yet.
|
||||
await expect(page.getByText("Permission required")).toBeVisible({ timeout: 120_000 });
|
||||
expect(newestFile(scratchBase!, name), "file must not exist before approval").toBeNull();
|
||||
|
||||
// Approve it.
|
||||
await page.getByRole("button", { name: "Allow once" }).click();
|
||||
|
||||
// Now it runs to completion and the artifact lands on disk.
|
||||
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 120_000 });
|
||||
const file = newestFile(scratchBase!, name);
|
||||
expect(file, `no ${name} found under ${scratchBase}`).toBeTruthy();
|
||||
expect(readFileSync(file!, "utf8").toLowerCase()).toContain("hello world");
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "fs";
|
||||
import { newestFile, scratchBaseIfReady, selectMode, sendTask, startCoworkSession } from "./helpers";
|
||||
|
||||
// LIVE end-to-end smoke: drive the real app against the real backend + a real model, ask it to
|
||||
// produce a file in Full-access mode, and verify the artifact lands on disk with correct contents.
|
||||
// This is the vertical the hermetic suite mocks (model, tool execution, file I/O, WS streaming).
|
||||
// Excluded from CI (separate config/dir) — run with `npm run e2e:live`.
|
||||
|
||||
const PROMPT =
|
||||
"Compute the first 20 Fibonacci numbers and write them to fib.md with a one-line explanation at the top.";
|
||||
// Distinctive Fibonacci values unlikely to appear in prose — a format-tolerant correctness check.
|
||||
const EXPECTED = ["144", "377", "987", "4181"];
|
||||
|
||||
test("live: agent writes fib.md to its scratch workspace, verified on disk", async ({ page }) => {
|
||||
const scratchBase = await scratchBaseIfReady();
|
||||
test.skip(!scratchBase, "live backend not ready — start coworker-server and configure a model");
|
||||
|
||||
await startCoworkSession(page);
|
||||
await selectMode(page, "Full access"); // run the write without an approval gate
|
||||
await sendTask(page, PROMPT);
|
||||
|
||||
// The artifact rail gains a file once the write tool has run (model + tool time).
|
||||
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 150_000 });
|
||||
|
||||
// Verify on disk — the strongest signal that the whole stack worked.
|
||||
const file = newestFile(scratchBase!, "fib.md");
|
||||
expect(file, `no fib.md found under ${scratchBase}`).toBeTruthy();
|
||||
const text = readFileSync(file!, "utf8");
|
||||
for (const n of EXPECTED) {
|
||||
expect(text, `fib.md should contain Fibonacci value ${n}`).toContain(n);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: e2e-tester
|
||||
name: E2E Tester
|
||||
icon: sparkle
|
||||
tagline: Throwaway persona for the live install smoke test
|
||||
description: Installed by the persona-install e2e:live test; writes a file on request.
|
||||
family: knowledge
|
||||
workspace: deliverable
|
||||
tools:
|
||||
- files
|
||||
default_permission_mode: auto
|
||||
---
|
||||
|
||||
You are the E2E Tester, a persona used only by an automated live test. When the user asks you to
|
||||
write a file, use your file tools to create it exactly as specified, then confirm in one short
|
||||
sentence. Do nothing else.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// Shared helpers for the LIVE smoke specs (real backend + real model). Kept out of the hermetic
|
||||
// suite (separate dir/config); see e2e/README.md.
|
||||
|
||||
export const BACKEND = "http://127.0.0.1:8765";
|
||||
|
||||
/** The expanded scratch base if the backend is up and a model is ready — else null (→ skip). */
|
||||
export async function scratchBaseIfReady(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/v1/settings`);
|
||||
const s = await res.json();
|
||||
if (res.ok && s.model_ready) {
|
||||
return String(s.scratch_base || "~/OpenWorker").replace(/^~(?=\/|$)/, homedir());
|
||||
}
|
||||
} catch {
|
||||
/* backend unreachable */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Newest `name` file across the per-session scratch dirs (each live session gets its own). */
|
||||
export function newestFile(scratchBase: string, name: string): string | null {
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = readdirSync(scratchBase);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const d of dirs) {
|
||||
const f = join(scratchBase, d, name);
|
||||
try {
|
||||
const st = statSync(f);
|
||||
if (!best || st.mtimeMs > best.mtime) best = { path: f, mtime: st.mtimeMs };
|
||||
} catch {
|
||||
/* not in this session dir */
|
||||
}
|
||||
}
|
||||
return best?.path ?? null;
|
||||
}
|
||||
|
||||
/** Open a fresh Cowork session via the split button's persona menu. */
|
||||
export async function startCoworkSession(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Choose a persona" }).click();
|
||||
await page.getByText(/Produce a deliverable/).click();
|
||||
}
|
||||
|
||||
/** Switch the composer's permission mode from the default "Ask for approval". */
|
||||
export async function selectMode(page: Page, label: "Full access" | "Plan" | "Discuss") {
|
||||
await page.getByText("Ask for approval").click();
|
||||
await page.getByText(label, { exact: true }).click();
|
||||
}
|
||||
|
||||
/** Type a task and send it. */
|
||||
export async function sendTask(page: Page, text: string) {
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill(text);
|
||||
// exact — "Send" is a substring of the Inbox control's "Sending approvals…" title when unattended.
|
||||
await page.getByRole("button", { name: "Send", exact: true }).click();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { scratchBaseIfReady, sendTask, startCoworkSession } from "./helpers";
|
||||
|
||||
// LIVE — Inbox / Unattended. With "Send to Inbox" on, a tool call that would normally block on an
|
||||
// inline approval card must instead route to the Inbox (so the agent runs unattended). We assert the
|
||||
// approval shows up in the Inbox for this session. Excluded from CI — run with `npm run e2e:live`.
|
||||
|
||||
test("live: unattended routes an approval to the Inbox", async ({ page }) => {
|
||||
const scratchBase = await scratchBaseIfReady();
|
||||
test.skip(!scratchBase, "live backend not ready — start coworker-server and configure a model");
|
||||
|
||||
const token = `INBOX-${Date.now()}`;
|
||||
const name = `inbox-${Date.now()}.txt`;
|
||||
|
||||
await startCoworkSession(page);
|
||||
|
||||
// Turn on "Send to Inbox" (unattended) via the composer's Inbox control, and wait until it's
|
||||
// persisted (the icon's title flips to the unattended wording only after setUnattended resolves).
|
||||
await page.getByRole("button", { name: "Inbox routing" }).click();
|
||||
await page.getByRole("switch", { name: "Send approvals to the Inbox" }).click();
|
||||
await expect(page.getByRole("button", { name: /works unattended/ })).toBeVisible();
|
||||
await page.locator(".fixed.inset-0.z-30").click(); // close the popover
|
||||
|
||||
// Keep the default Ask-for-approval mode: the write would normally block inline, but unattended
|
||||
// routes it to the Inbox.
|
||||
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
|
||||
|
||||
// Open the Inbox; the approval appears there (its session chip carries this session's title, which
|
||||
// is the prompt — so it contains the unique filename).
|
||||
await page.getByText("Inbox", { exact: true }).click();
|
||||
await expect(page.getByText(name).first()).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByRole("button", { name: "Approve" }).first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { scratchBaseIfReady, selectMode, sendTask, startCoworkSession } from "./helpers";
|
||||
|
||||
// LIVE #6 — persistence & resume. After a completed turn, reloading the page must not lose the work:
|
||||
// the session persists in the sidebar and reopens with its full transcript and its artifact. We
|
||||
// reopen it explicitly (rather than relying on which session auto-restores — several sessions can
|
||||
// share the same updated_at second). Excluded from CI — run with `npm run e2e:live`.
|
||||
|
||||
test("live: a session's transcript and artifact survive a page reload", async ({ page }) => {
|
||||
const scratchBase = await scratchBaseIfReady();
|
||||
test.skip(!scratchBase, "live backend not ready — start coworker-server and configure a model");
|
||||
|
||||
const token = `PERSIST-${Date.now()}`;
|
||||
// Unique filename — appears early in the session title (so it survives title truncation and is a
|
||||
// reliable click target in the sidebar), and is the artifact name.
|
||||
const name = `note-${Date.now()}.txt`;
|
||||
|
||||
await startCoworkSession(page);
|
||||
await selectMode(page, "Full access");
|
||||
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
|
||||
|
||||
// Turn finishes (artifact lands) and the token is in the transcript.
|
||||
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 150_000 });
|
||||
await expect(page.getByText(token).first()).toBeVisible();
|
||||
|
||||
// Reload, then reopen this session from the sidebar (it must have persisted there).
|
||||
await page.reload();
|
||||
await page.getByText(name).first().click({ timeout: 60_000 });
|
||||
|
||||
// Reopened with its transcript restored (the token) and its artifact back on the rail.
|
||||
await expect(page.getByText(token).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { newestFile, scratchBaseIfReady, selectMode, sendTask } from "./helpers";
|
||||
|
||||
// LIVE capstone — install a persona from a local-directory bundle, enable + surface it, start a
|
||||
// session as it, and have it do real work. Exercises the whole persona pipeline: manifest parse +
|
||||
// snapshot on install, lifecycle (enable/surface), session creation, and execution. Excluded from
|
||||
// CI — run with `npm run e2e:live`. Idempotent: re-installing overwrites the snapshot.
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_DIR = path.join(here, "fixtures", "persona"); // holds e2e-tester.md
|
||||
|
||||
test("live: install a persona from a directory, enable it, and run a task as it", async ({ page }) => {
|
||||
const scratchBase = await scratchBaseIfReady();
|
||||
test.skip(!scratchBase, "live backend not ready — start coworker-server and configure a model");
|
||||
|
||||
const token = `PERSONA-${Date.now()}`;
|
||||
const name = `persona-${Date.now()}.txt`;
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Open persona management (Settings ▸ Personas) via the New-session menu.
|
||||
await page.getByRole("button", { name: "Choose a persona" }).click();
|
||||
await page.getByText(/Manage personas/).click();
|
||||
await expect(page.getByText("Add personas")).toBeVisible();
|
||||
|
||||
// Install from the local directory bundle.
|
||||
await page.getByRole("combobox").selectOption("dir");
|
||||
await page.getByPlaceholder("/path/to/personas").fill(FIXTURE_DIR);
|
||||
await page.getByRole("button", { name: "Install" }).click();
|
||||
await expect(page.getByText(/Installed \d+ persona/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Enable + surface it in the picker. Idempotent across re-runs (skip if already on), and click +
|
||||
// await rather than check() — these are controlled React checkboxes (async updatePersona re-render).
|
||||
const row = page.locator("div.flex.items-center.gap-4").filter({ hasText: "E2E Tester" });
|
||||
const ensureChecked = async (i: number) => {
|
||||
const box = row.getByRole("checkbox").nth(i);
|
||||
if (!(await box.isChecked())) {
|
||||
await box.click();
|
||||
await expect(box).toBeChecked();
|
||||
}
|
||||
};
|
||||
await ensureChecked(0); // Enabled
|
||||
await ensureChecked(1); // In picker (enabled only once Enabled is on)
|
||||
|
||||
// Leave Settings (so the settings rows unmount), then start a fresh session AS the new persona.
|
||||
// Select by the unique tagline — it appears only on the dropdown item, whereas the name "E2E
|
||||
// Tester" also shows in the top bar/sidebar once a session is on it.
|
||||
await page.getByRole("button", { name: "New session" }).click();
|
||||
await page.getByRole("button", { name: "Choose a persona" }).click();
|
||||
await page.getByText(/Throwaway persona/).click();
|
||||
await expect(page.getByText("E2E Tester").first()).toBeVisible(); // the session is this persona
|
||||
|
||||
// New sessions start in "Ask for approval" regardless of the persona's declared mode (a safety
|
||||
// default for freshly-installed personas), so set Full access to let the write run to completion.
|
||||
await selectMode(page, "Full access");
|
||||
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
|
||||
|
||||
// The installed persona should do the work. Non-Cowork personas don't render the Artifacts rail,
|
||||
// so wait on the file itself (ground truth) rather than a UI signal.
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const f = newestFile(scratchBase!, name);
|
||||
return f ? readFileSync(f, "utf8") : "";
|
||||
},
|
||||
{ timeout: 150_000, message: `${name} with the token never appeared under ${scratchBase}` },
|
||||
)
|
||||
.toContain(token);
|
||||
});
|
||||
Reference in New Issue
Block a user