Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
f25bb0e
feat(evals): timeline primitive, workspace seeding, and honest visual…
benjaminshafii Jul 30, 2026
7842cd3
fix(evals): seed completes onboarding and engine boot; wait on produc…
benjaminshafii Jul 30, 2026
b48e484
fix(evals): keep app-smoke on the product's own workspace creation path
benjaminshafii Jul 30, 2026
5c3053a
test(evals): app pointed at a TLS-broken den must name the fault (wel…
benjaminshafii Jul 30, 2026
db48332
chore(evals): single-spec sandbox runner
benjaminshafii Jul 30, 2026
958b655
test(evals): adapt the TLS-fault spec to the real pre-workspace surface
benjaminshafii Jul 30, 2026
3b344cc
test(evals): choose the sign-in control from real buttons
benjaminshafii Jul 30, 2026
80439ce
test(evals): TLS-intercepted den — no false 'connected', and diagnost…
benjaminshafii Jul 30, 2026
e5f57bc
test(evals): one frame — sign-in against a broken den changes nothing…
benjaminshafii Jul 30, 2026
2d3b67a
test(evals): name the TLS spec after what it actually proves
benjaminshafii Jul 30, 2026
18d6c1a
fix(evals): short per-probe timeouts so busy renderers are retried, n…
benjaminshafii Jul 30, 2026
a1171f0
fix(evals): read the created workspace via the proven in-page server …
benjaminshafii Jul 30, 2026
eacfbd5
test(evals): poll patiently through the cold-profile renderer block
benjaminshafii Jul 30, 2026
f5ac9d1
fix(evals): poll for the created workspace record through renderer bl…
benjaminshafii Jul 30, 2026
da4af18
fix(evals): resolve the workspace id from product state after onboard…
benjaminshafii Jul 30, 2026
c32d948
fix(evals): idempotent reads retry through renderer blocks
benjaminshafii Jul 30, 2026
69288e3
fix(evals): retry reads to a deadline, not a fixed attempt count
benjaminshafii Jul 30, 2026
575af66
fix(evals): re-attach to the app's current page target when evaluatio…
benjaminshafii Jul 30, 2026
1e4895f
fix(evals): background prep does not make the welcome surface un-inte…
benjaminshafii Jul 30, 2026
42843c4
chore(evals): verify the X display actually answers before spawning E…
benjaminshafii Jul 30, 2026
9a32f86
chore(evals): clear stale Electron processes before each sandbox spec…
benjaminshafii Jul 30, 2026
4e0a83c
fix(evals): resolve the workspace id after onboarding completes, not …
benjaminshafii Jul 30, 2026
6c0876d
fix(evals): report the workspace id even while the welcome surface is…
benjaminshafii Jul 30, 2026
d83ce96
test(evals): assert the first-run workspace id after onboarding adopt…
benjaminshafii Jul 30, 2026
7253af2
test(evals): use the resolved workspace id; report loaded skills on f…
benjaminshafii Jul 30, 2026
a580479
fix(evals): parse the skill name from the row, and drop a regex that …
benjaminshafii Jul 30, 2026
f8646d9
test(evals): wait for the assistant to settle; use the session contro…
benjaminshafii Jul 30, 2026
9673948
test(evals): wait for output to stabilise; stop assuming session ids …
benjaminshafii Jul 30, 2026
4b6de28
fix(evals): settings and extensions surfaces are interactive too
benjaminshafii Jul 30, 2026
7f398f9
test(evals): read the created session id from the product's session list
benjaminshafii Jul 30, 2026
ee37857
test(evals): drop an exact timing threshold that can flake by a milli…
benjaminshafii Jul 30, 2026
d669cef
test(evals): first run signs in through a real browser, then shares a…
benjaminshafii Jul 30, 2026
d7ba62f
chore(evals): verify the display in the full runner; give single-spec…
benjaminshafii Jul 30, 2026
9a5152f
refactor(evals): move environment healing behind the components that …
benjaminshafii Jul 31, 2026
b9e2c13
test(evals): match the real desktop-auth handoff URL and read Den's d…
benjaminshafii Jul 31, 2026
1a4dd6b
test(evals): switch the browser from sign-up to sign-in for a seeded …
benjaminshafii Jul 31, 2026
4a8b851
chore(evals): let a single-spec run ensure the Den stack when the spe…
benjaminshafii Jul 31, 2026
04f4423
test(evals): collapse workspace arrangement to the proven onboarding …
benjaminshafii Jul 31, 2026
184579a
test(evals): drive den web's real two-step sign-in and read the sign-…
benjaminshafii Jul 31, 2026
0cfdd4f
fix(evals): keep pnpm version redirection warm inside isolated Electr…
benjaminshafii Jul 31, 2026
c667d8e
chore(evals): export den eval env only when a den actually answers
benjaminshafii Jul 31, 2026
ce19169
test(evals): scroll the claimed skill row into view before its screen…
benjaminshafii Jul 31, 2026
2d0e2cc
fix(evals): survive renderer freeze bursts while reading the skills menu
benjaminshafii Jul 31, 2026
c271c3a
test(evals): reveal the menu row a visual claim names before its scre…
benjaminshafii Jul 31, 2026
9dab9ce
test(evals): assert the capability menu visuals a person can actually…
benjaminshafii Jul 31, 2026
a73f946
test(evals): arm the slow-cloud scenario separately and drive the men…
benjaminshafii Jul 31, 2026
167f6df
test(evals): arrange the real cloud-connected desktop state for slow-…
benjaminshafii Jul 31, 2026
ee0d498
test(evals): drop the slow-cloud skills block whose arrangement canno…
benjaminshafii Jul 31, 2026
86c0f67
test(evals): wait for the sign-in outcome, and give org specs a works…
benjaminshafii Jul 31, 2026
88397f7
test(evals): send the role the marketplace access grant API requires
benjaminshafii Jul 31, 2026
07fbc41
test(evals): read shared skill names from the plugin's resolved compo…
benjaminshafii Jul 31, 2026
2053e03
test(evals): poll the model catalog past its first paint, and pin the…
benjaminshafii Jul 31, 2026
db8c997
test(evals): pin the managed defect without typing (no editor without…
benjaminshafii Jul 31, 2026
eb59637
test(evals): pin only the persistent notice after Retry (the button r…
benjaminshafii Jul 31, 2026
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
3 changes: 2 additions & 1 deletion evals/packages/behaviors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dependencies": {
"@openwork/cdp": "workspace:*",
"@openwork/labs": "workspace:*",
"@openwork/matchers": "workspace:*"
"@openwork/matchers": "workspace:*",
"@openwork/timeline": "workspace:*"
}
}
151 changes: 151 additions & 0 deletions evals/packages/behaviors/src/browser-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { timed } from "@openwork/timeline";
import { control, evalIn, waitFor } from "./desktop.ts";
import type { Surface } from "@openwork/cdp";

/**
* The desktop signs in by opening the system browser and finishing there. To
* observe that faithfully we capture what the app actually asks the OS to open
* (via a PATH shim for xdg-open, which is what Electron's shell.openExternal
* calls on Linux) rather than trusting that a browser appeared.
*
* Nothing here modifies the product: the app opens a URL exactly as it always
* does; we just record where it pointed.
*/

export interface UrlCapture {
/** Prepend to PATH when spawning the app. */
binDir: string;
/** Every URL the app asked the OS to open, oldest first. */
opened(): Promise<string[]>;
/** Wait for a URL matching a predicate, so a spec never races the handoff. */
waitForUrl(match: (url: string) => boolean, opts?: { timeoutMs?: number }): Promise<string>;
}

export async function captureOpenedUrls(): Promise<UrlCapture> {
const dir = await mkdtemp(join(tmpdir(), "openwork-open-external-"));
const logPath = join(dir, "opened-urls.log");
await writeFile(logPath, "", "utf8");
const shim = join(dir, "xdg-open");
await writeFile(shim, `#!/usr/bin/env bash\nprintf '%s\\n' "$1" >> ${JSON.stringify(logPath)}\nexit 0\n`, "utf8");
await chmod(shim, 0o755);

const opened = async (): Promise<string[]> => {
const contents = await readFile(logPath, "utf8").catch(() => "");
return contents.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
};

return {
binDir: dir,
opened,
async waitForUrl(match, { timeoutMs = 60_000 } = {}) {
const deadline = Date.now() + timeoutMs;
let seen: string[] = [];
while (Date.now() < deadline) {
seen = await opened();
const hit = seen.find((url) => match(url));
if (hit) return hit;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`The app never asked to open a matching URL within ${timeoutMs}ms. Opened: ${seen.join(", ") || "<none>"}`);
},
};
}

/**
* Sign in on a real den web page in a real browser, the way a person does.
*
* Observed shape: den auth is two steps, not one form. First an email-only
* step ("Enter your email and we'll send you to the right sign-in step",
* button "Next"); only after that does the password step appear (button
* "Sign in"). There is no sign-up/sign-in toggle — the email step routes.
*/
export async function signInInBrowser(
browser: Surface,
url: string,
credentials: { email: string; password: string },
): Promise<void> {
await timed("browser.signIn", async () => {
await evalIn(browser, `window.location.href = ${JSON.stringify(url)}`);
await waitFor(browser, `(() => {
if (document.querySelector('input[type="password"], input[name="password"]')) return true;
const email = document.querySelector('input[type="email"], input[name="email"]');
if (!email) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
setter.call(email, ${JSON.stringify(credentials.email)});
email.dispatchEvent(new Event("input", { bubbles: true }));
const next = [...document.querySelectorAll("button")]
.find((candidate) => /next|continue|sign ?in/i.test(candidate.textContent ?? "") && !candidate.disabled);
if (!next) return false;
next.click();
return true;
})()`, { timeoutMs: 120_000, label: "den email step submitted" });
await waitFor(browser, `(() => {
if (/signed in/i.test(document.body?.innerText ?? "")) return true;
const password = document.querySelector('input[type="password"], input[name="password"]');
if (!password) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
setter.call(password, ${JSON.stringify(credentials.password)});
password.dispatchEvent(new Event("input", { bubbles: true }));
const submit = [...document.querySelectorAll("button")]
.find((candidate) => /sign ?in|continue|log ?in/i.test(candidate.textContent ?? "") && !candidate.disabled);
if (!submit) return false;
submit.click();
return true;
})()`, { timeoutMs: 60_000, label: "den password step submitted" });
// Signed in means the outcome page, not a submit in flight ("Working...").
await waitFor(browser, `(() => {
const text = document.body?.innerText ?? "";
if (/signed in/i.test(text)) return true;
return [...document.querySelectorAll("input")]
.some((input) => (input.value ?? "").startsWith("openwork://"));
})()`, { timeoutMs: 60_000, label: "den sign-in outcome" });
}, credentials.email);
}

/**
* Read the deep link the browser is handed once sign-in completes.
*
* Observed shape: the app opens `<den>/?mode=sign-up&desktopAuth=1&desktopScheme=openwork`,
* the person signs in there, and Den shows "You're signed in" with an
* "Open OpenWork" button plus a readonly input holding the sign-in code —
* the full `openwork://den-auth?grant=…&denBaseUrl=…` URL a person would
* copy-paste into the app. Reading that input keeps the grant the real one
* Den issued for this session.
*/
export async function readHandoffDeepLink(browser: Surface, { timeoutMs = 120_000 } = {}): Promise<string> {
const found = await waitFor(browser, `(() => {
const fromInput = [...document.querySelectorAll("input")]
.map((input) => input.value)
.find((value) => typeof value === "string" && value.startsWith("openwork://") && value.includes("grant="));
if (fromInput) return fromInput;
const fromAnchor = [...document.querySelectorAll('a[href^="openwork://"]')]
.map((anchor) => anchor.getAttribute("href"))
.find((href) => typeof href === "string" && href.includes("grant="));
if (fromAnchor) return fromAnchor;
const inText = (document.body?.innerText ?? "").match(/openwork:\\/\\/[^\\s"']+grant=[^\\s"']+/);
return inText ? inText[0] : false;
})()`, { timeoutMs, label: "handoff deep link in the browser" });
if (typeof found !== "string") throw new Error("Could not read a handoff deep link from the browser page.");
return found;
}

/**
* Complete the hop back into the desktop.
*
* A real OS dispatches `openwork://den-auth?grant=…` to the app. A container has
* no protocol handler registered, so we hand the grant to the product's own
* documented entry point for exactly this situation (`auth.exchange-grant`,
* described in-product as signing in with a handoff grant). The grant itself is
* the real one the app generated and the browser session approved — only the OS
* dispatch is bridged, and that is stated wherever this is used.
*/
export async function completeDesktopHandoff(app: Surface, deepLinkOrGrantUrl: string, denBaseUrl: string): Promise<string> {
const url = new URL(deepLinkOrGrantUrl);
const grant = url.searchParams.get("grant") ?? "";
if (!grant) throw new Error(`No grant in the handoff URL: ${deepLinkOrGrantUrl}`);
await control(app, "auth.exchange-grant", { grant, baseUrl: denBaseUrl });
return grant;
}
155 changes: 155 additions & 0 deletions evals/packages/behaviors/src/cloud-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { timed } from "@openwork/timeline";
import { denFetch } from "./den.ts";
import type { DenSession } from "./den.ts";

/**
* Authoring and sharing on the cloud side: a skill lives inside a plugin, and a
* plugin becomes shareable by being assigned to a marketplace that colleagues
* (or their teams) can use.
*
* These are plain API behaviours over the real den contract
* (ee/apps/den-api/src/routes/org/plugin-system), so the same calls work from a
* spec or a support script.
*/

export interface MarketplaceFacts {
id: string;
name: string;
}

export interface PluginFacts {
id: string;
name: string;
componentIds: string[];
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function requireId(value: unknown, what: string, status: number, body: unknown): string {
const item = isRecord(value) && isRecord(value.item) ? value.item : isRecord(value) ? value : null;
const id = item && typeof item.id === "string" ? item.id : "";
if (!id) throw new Error(`${what} did not return an id (status ${status}): ${JSON.stringify(body).slice(0, 300)}`);
return id;
}

export async function createMarketplace(admin: DenSession, input: { name: string; description?: string }): Promise<MarketplaceFacts> {
return timed("cloud.createMarketplace", async () => {
const { response, body } = await denFetch(admin, "/v1/marketplaces", {
method: "POST",
headers: { authorization: `Bearer ${admin.token}` },
body: JSON.stringify({ name: input.name, description: input.description ?? null }),
});
if (!response.ok) throw new Error(`Creating marketplace failed (${response.status}): ${JSON.stringify(body).slice(0, 300)}`);
return { id: requireId(body, "Creating a marketplace", response.status, body), name: input.name };
});
}

/**
* Create a plugin that CONTAINS a skill, and (optionally) publish it to a
* marketplace in the same call — the contract supports both, which mirrors what
* a person does in one sitting: author the skill, then share it.
*/
export async function createPluginWithSkill(
admin: DenSession,
input: { name: string; skillName: string; skillBody: string; marketplaceId?: string; orgWide?: boolean },
): Promise<PluginFacts> {
return timed("cloud.createPluginWithSkill", async () => {
const skillMarkdown = `---\nname: ${input.skillName}\ndescription: Shared by an eval to prove skill sharing works.\n---\n\n${input.skillBody}\n`;
const { response, body } = await denFetch(admin, "/v1/plugins", {
method: "POST",
headers: { authorization: `Bearer ${admin.token}` },
body: JSON.stringify({
name: input.name,
description: "Created by the first-run cloud sharing eval.",
orgWide: input.orgWide ?? true,
marketplaceId: input.marketplaceId,
components: [{ type: "skill", input: { rawSourceText: skillMarkdown } }],
}),
});
if (!response.ok) throw new Error(`Creating plugin failed (${response.status}): ${JSON.stringify(body).slice(0, 300)}`);
const id = requireId(body, "Creating a plugin", response.status, body);
const item = isRecord(body) && isRecord(body.item) ? body.item : {};
const components = Array.isArray(item.components) ? item.components : [];
const componentIds = components
.map((component) => (isRecord(component) && typeof component.id === "string" ? component.id : ""))
.filter((value) => value.length > 0);
return { id, name: input.name, componentIds };
});
}

export async function assignPluginToMarketplace(admin: DenSession, marketplaceId: string, pluginId: string): Promise<void> {
await timed("cloud.assignPluginToMarketplace", async () => {
const { response, body } = await denFetch(admin, `/v1/marketplaces/${marketplaceId}/plugins`, {
method: "POST",
headers: { authorization: `Bearer ${admin.token}` },
body: JSON.stringify({ pluginId }),
});
if (!response.ok) throw new Error(`Assigning plugin to marketplace failed (${response.status}): ${JSON.stringify(body).slice(0, 300)}`);
});
}

/**
* Give a colleague (or their team, or the whole org) access to the marketplace.
* The API requires a role: viewer covers "can see and use what's shared".
*/
export async function grantMarketplaceAccess(
admin: DenSession,
marketplaceId: string,
grant: ({ orgWide: true } | { orgMembershipId: string } | { teamId: string }) & { role?: "viewer" | "editor" | "manager" },
): Promise<void> {
await timed("cloud.grantMarketplaceAccess", async () => {
const { response, body } = await denFetch(admin, `/v1/marketplaces/${marketplaceId}/access`, {
method: "POST",
headers: { authorization: `Bearer ${admin.token}` },
body: JSON.stringify({ role: "viewer", ...grant }),
});
if (!response.ok) throw new Error(`Granting marketplace access failed (${response.status}): ${JSON.stringify(body).slice(0, 300)}`);
});
}

export interface ResolvedMarketplaceFacts {
pluginNames: string[];
skillNames: string[];
raw: unknown;
}

/**
* What a member actually sees in a marketplace — read as that member, not the
* admin. The resolved marketplace lists plugins (with component COUNTS only);
* the skill names live on each plugin's own resolved components
* (items[].configObject with objectType "skill" and the skill's title).
*/
export async function readResolvedMarketplace(member: DenSession, marketplaceId: string): Promise<ResolvedMarketplaceFacts> {
return timed("cloud.readResolvedMarketplace", async () => {
const { response, body } = await denFetch(member, `/v1/marketplaces/${marketplaceId}/resolved`, {
headers: { authorization: `Bearer ${member.token}` },
});
if (!response.ok) throw new Error(`Reading the resolved marketplace failed (${response.status}): ${JSON.stringify(body).slice(0, 300)}`);
const item = isRecord(body) && isRecord(body.item) ? body.item : isRecord(body) ? body : {};
const plugins = Array.isArray(item.plugins) ? item.plugins : [];
const pluginNames: string[] = [];
const pluginIds: string[] = [];
for (const plugin of plugins) {
if (!isRecord(plugin)) continue;
if (typeof plugin.name === "string") pluginNames.push(plugin.name);
if (typeof plugin.id === "string") pluginIds.push(plugin.id);
}
const skillNames: string[] = [];
for (const pluginId of pluginIds) {
const resolved = await denFetch(member, `/v1/plugins/${encodeURIComponent(pluginId)}/resolved`, {
headers: { authorization: `Bearer ${member.token}` },
});
if (!resolved.response.ok) continue; // Not readable by this member = not visible to them.
const items = isRecord(resolved.body) && Array.isArray(resolved.body.items) ? resolved.body.items : [];
for (const component of items) {
if (!isRecord(component) || !isRecord(component.configObject)) continue;
const configObject = component.configObject;
if (configObject.objectType !== "skill") continue;
if (typeof configObject.title === "string" && configObject.title) skillNames.push(configObject.title);
}
}
return { pluginNames, skillNames, raw: body };
});
}
29 changes: 24 additions & 5 deletions evals/packages/behaviors/src/desktop-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ export async function signInDesktopAs(app: Surface, den: DenRef, member: DenSess
timeoutMs: 60_000,
label: "active org resolved",
});
await waitFor(app, "window.location.hash.includes('/onboarding')", {
// A first-time member lands on organization onboarding; a member whose app
// already has a workspace can come straight back to it.
await waitFor(app, `window.location.hash.includes("/onboarding") || /\\/(workspace|session)/.test(window.location.hash)`, {
timeoutMs: 60_000,
label: "organization onboarding route",
label: "organization onboarding or workspace route",
});
}

Expand Down Expand Up @@ -101,6 +103,13 @@ async function resolveWorkspaceId(app: Surface): Promise<string> {
return workspaceIdFromRoute(await currentHash(app));
}

/**
* THE arrangement path for a workspace: the product's own onboarding, driven
* the way a person drives it. A previous API seed (POST /workspaces/local +
* activate) produced a state the product itself never produces — a workspace
* with no engine and no model catalog — and specs failed on that arrangement,
* not on their subject. If a spec needs a workspace, it goes through here.
*/
export async function createAndSelectWorkspace(
app: Surface,
input: { path: string },
Expand All @@ -109,10 +118,20 @@ export async function createAndSelectWorkspace(
const route = await currentHash(app);
if (route.includes("/welcome")) {
const workspace = await createLocalWorkspaceViaUi(app, input);
workspaceId = workspace.id || (await resolveWorkspaceId(app));
await clickButton(app, "Skip and use the free model", { timeoutMs: 30_000 });
await waitForText(app, "How did you hear about OpenWork?", { timeoutMs: 30_000 });
await clickButton(app, "Skip and use the free model", { timeoutMs: 90_000 });
await waitForText(app, "How did you hear about OpenWork?", { timeoutMs: 90_000 });
await clickButton(app, "Skip", { timeoutMs: 15_000 });
// Only now is the workspace actually selected: resolving before the
// onboarding steps finish reads an id the app has not adopted yet.
workspaceId = workspace.id;
if (!workspaceId) {
await waitFor(app, `Boolean(localStorage.getItem("openwork.react.activeWorkspace"))
|| /\\/workspace\\/[^/?#]+/.test(window.location.hash)`, {
timeoutMs: 180_000,
label: "workspace selected after onboarding",
});
workspaceId = await resolveWorkspaceId(app);
}
} else {
if (route.includes("/onboarding")) await completeOrganizationOnboarding(app);
workspaceId = await resolveWorkspaceId(app);
Expand Down
Loading
Loading