Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out. - Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language. - OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol. - Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar. - Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used. - Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors. - Per-agent model picker lists the provider's whole model list, live when connected. - Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id. - The stored "clock" theme reads back instead of falling to "system". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
9.2 KiB
TypeScript
175 lines
9.2 KiB
TypeScript
import { _electron as electron, expect, test, type Page } from "@playwright/test";
|
|
import { mkdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan";
|
|
import { cameraFor } from "../../src/renderer/src/pages/office/simulation";
|
|
import { isoFrame, isoPixelSize, toIso } from "../../src/renderer/src/pages/office/iso";
|
|
import type { FloorPlan, Vec } from "../../src/renderer/src/pages/office/office-types";
|
|
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
|
|
|
|
test("the office floor opens agent details on click", async () => {
|
|
const profileDir = join(tmpdir(), `agentcompany-new-office-${randomUUID()}`);
|
|
mkdirSync(profileDir, { recursive: true });
|
|
const app = await electron.launch({
|
|
args: [resolve("out/main/index.js")],
|
|
env: {
|
|
...process.env,
|
|
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
|
|
AGENTCOMPANY_NEW_AUTORUN: "0",
|
|
AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: seedBuiltinSkillsFixture(join(profileDir, "builtin-src")),
|
|
},
|
|
});
|
|
|
|
try {
|
|
const page = await app.firstWindow();
|
|
// The office renders in a rAF loop; a throwing frame (e.g. a broken
|
|
// effect draw) surfaces only as a page error, so collect them.
|
|
const pageErrors: string[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.getByRole("button", { name: "Get started" }).click();
|
|
await page.getByLabel("API key").fill("office-e2e-key");
|
|
await page.getByLabel("Model").fill("gpt-5.4");
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
await page.getByLabel("Company name").fill("Pixel Works");
|
|
await page.getByLabel("Your name").fill("Morgan Reyes");
|
|
await page.getByLabel("Description").fill("Office e2e coverage.");
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
await page.getByLabel("Goal").fill("Verify the office floor.");
|
|
await page.getByLabel("Acceptance criteria").fill("Clicking an agent opens their card.");
|
|
await page.getByRole("button", { name: "Launch" }).click();
|
|
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Skip" }).click();
|
|
await expect(page.getByRole("button", { name: /Pixel Works/ })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Office", exact: true }).click();
|
|
const canvas = page.locator("canvas");
|
|
await expect(canvas).toBeVisible();
|
|
await page.waitForTimeout(300);
|
|
|
|
// Onboarding seeds exactly one agent (the CEO), so the floor plan and
|
|
// camera are fully determined by the same pure modules the app uses.
|
|
const box = await canvas.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
const plan = buildFloorPlan(0);
|
|
const ratio = await page.evaluate(() => window.devicePixelRatio);
|
|
const { x: clickX, y: clickY } = onScreen(plan, box!, ratio, plan.ceoSeat, 20);
|
|
|
|
await page.mouse.click(clickX, clickY);
|
|
const card = page.getByRole("dialog", { name: "Agent CEO" });
|
|
await expect(card).toBeVisible();
|
|
await expect(card.getByText("CEO", { exact: true })).toBeVisible();
|
|
await expect(card.getByText("Chief Executive Officer")).toBeVisible();
|
|
await expect(card.getByText("Executive", { exact: true })).toBeVisible();
|
|
|
|
// The card sits beside the clicked agent, not pinned to a screen corner.
|
|
const cardBox = await card.boundingBox();
|
|
expect(cardBox).not.toBeNull();
|
|
const gapToSprite = Math.min(
|
|
Math.abs(cardBox!.x - clickX),
|
|
Math.abs(cardBox!.x + cardBox!.width - clickX),
|
|
);
|
|
expect(gapToSprite).toBeLessThan(80);
|
|
expect(Math.abs(cardBox!.y + cardBox!.height / 2 - clickY)).toBeLessThan(160);
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(card).not.toBeVisible();
|
|
|
|
// The office shows real state: a blocked task makes its agent blocked, with the reason on the card.
|
|
const db = new DatabaseSync(join(profileDir, "agentcompany.db"));
|
|
const blocked = db.prepare("update tasks set blocked_reason = 'needs the staging key' where assignee_agent_id = (select id from agents where name = 'CEO') and status <> 'done'").run();
|
|
db.close();
|
|
expect(blocked.changes).toBeGreaterThan(0);
|
|
await app.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed", version: Number.MAX_SAFE_INTEGER });
|
|
});
|
|
await expect(page.getByLabel("Show a person").locator("option", { hasText: "CEO, Blocked" })).toHaveCount(1);
|
|
await page.getByLabel("Show a person").selectOption({ label: "CEO, Blocked" });
|
|
await expect(card.getByText("Blocked: needs the staging key")).toBeVisible();
|
|
await expect(card.getByText("Waiting on you.")).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(card).not.toBeVisible();
|
|
|
|
// Clicking empty corridor floor selects nothing.
|
|
const corridor = onScreen(plan, box!, ratio, { x: 500, y: 320 }, 0);
|
|
await page.mouse.click(corridor.x, corridor.y);
|
|
await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible();
|
|
|
|
// WASD input is accepted without disturbing the page.
|
|
for (const key of ["KeyD", "KeyS", "KeyA", "KeyW"]) {
|
|
await page.keyboard.down(key);
|
|
await page.waitForTimeout(120);
|
|
await page.keyboard.up(key);
|
|
}
|
|
await expect(canvas).toBeVisible();
|
|
|
|
// The office offers no pretend controls over work.
|
|
await expect(page.getByRole("button", { name: "Whip" })).toHaveCount(0);
|
|
|
|
// Hired agents take open-floor desks in hire order and are clickable too.
|
|
await hireAgent(page, "Ava Patel", "Designer", "Design");
|
|
await hireAgent(page, "Ken Ito", "Engineer", "Engineering");
|
|
|
|
// The org chart draws everyone as the office's pixel people: the chair, the CEO and both hires.
|
|
await page.getByRole("button", { name: "Team", exact: true }).click();
|
|
const avatars = page.locator(".org-node-card canvas.pixel-avatar");
|
|
await expect(avatars).toHaveCount(4);
|
|
const painted = await avatars.evaluateAll((canvases) => canvases.map((canvas) => {
|
|
const { data } = (canvas as HTMLCanvasElement).getContext("2d")!.getImageData(0, 0, (canvas as HTMLCanvasElement).width, (canvas as HTMLCanvasElement).height);
|
|
let opaque = 0;
|
|
for (let i = 3; i < data.length; i += 4) if (data[i] === 255) opaque++;
|
|
return opaque;
|
|
}));
|
|
for (const count of painted) expect(count).toBeGreaterThan(100);
|
|
// Different people look different.
|
|
const looks = await avatars.evaluateAll((canvases) => canvases.map((canvas) => (canvas as HTMLCanvasElement).toDataURL()));
|
|
expect(new Set(looks).size).toBe(4);
|
|
const avaLook = await page.locator(".org-node-card", { hasText: "Ava Patel" }).locator("canvas.pixel-avatar").evaluate((canvas) => (canvas as HTMLCanvasElement).toDataURL());
|
|
await page.getByRole("button", { name: "Office", exact: true }).click();
|
|
await expect(canvas).toBeVisible();
|
|
await page.waitForTimeout(300);
|
|
|
|
const populated = buildFloorPlan(2);
|
|
const populatedBox = await canvas.boundingBox();
|
|
const firstDesk = onScreen(populated, populatedBox!, ratio, populated.desks[0].seat, 20);
|
|
await page.mouse.click(firstDesk.x, firstDesk.y);
|
|
const avaCard = page.getByRole("dialog", { name: "Agent Ava Patel" });
|
|
await expect(avaCard).toBeVisible();
|
|
await expect(avaCard.getByText("Design", { exact: true })).toBeVisible();
|
|
// The card shows the same pixel face the org chart drew for Ava.
|
|
expect(await avaCard.locator("canvas.pixel-avatar").evaluate((canvas) => (canvas as HTMLCanvasElement).toDataURL())).toBe(avaLook);
|
|
|
|
const outDir = resolve("test-results/screenshots");
|
|
mkdirSync(outDir, { recursive: true });
|
|
await page.screenshot({ path: join(outDir, "19-office-populated.png"), fullPage: false });
|
|
|
|
expect(pageErrors).toEqual([]);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
async function hireAgent(page: Page, name: string, role: string, department: string): Promise<void> {
|
|
await page.getByRole("button", { name: "Team", exact: true }).click();
|
|
await page.getByRole("button", { name: "Hire", exact: true }).click();
|
|
await page.getByLabel("Name").fill(name);
|
|
await page.getByLabel("Role").fill(role);
|
|
await page.getByLabel("Department").fill(department);
|
|
await page.getByLabel("Monthly budget").fill("100");
|
|
await page.locator(".modal").getByRole("button", { name: "Hire" }).click();
|
|
await expect(page.getByText(name).first()).toBeVisible();
|
|
}
|
|
|
|
/** Where a plan point, lifted by `lift` iso pixels, is drawn: the same projection, camera and pixel size the page uses, with the player at the entrance. */
|
|
function onScreen(plan: FloorPlan, box: { x: number; y: number; width: number; height: number }, ratio: number, point: Vec, lift: number): Vec {
|
|
const pixel = isoPixelSize(ratio);
|
|
const frame = isoFrame(plan.world);
|
|
const camera = cameraFor(frame.size, box.width / pixel, box.height / pixel, toIso(frame, plan.entrance));
|
|
const at = toIso(frame, point, lift);
|
|
return { x: box.x + (at.x - Math.round(camera.x)) * pixel, y: box.y + (at.y - Math.round(camera.y)) * pixel };
|
|
}
|