Add contract and Playwright PR checks

This commit is contained in:
Anthony Ettinger 2026-06-06 11:30:12 +00:00
parent 447974dbba
commit 254233c3ab
11 changed files with 277 additions and 45 deletions

View file

@ -6,7 +6,9 @@
"main": "./dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx src/index.ts"
"dev": "tsx src/index.ts",
"test": "vitest run src",
"test:contract": "vitest run src/contract.test.ts"
},
"dependencies": {
"@logicsrc/plugin-core": "file:../../packages/plugin-core",
@ -16,6 +18,7 @@
"@logicsrc/validators": "file:../../packages/validators"
},
"devDependencies": {
"tsx": "^4.21.0"
"tsx": "^4.21.0",
"vitest": "^4.0.8"
}
}

View file

@ -0,0 +1,88 @@
import type { Server } from "node:http";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCommandBoardServer } from "./index.js";
let server: Server;
let baseUrl: string;
beforeAll(async () => {
server = createCommandBoardServer();
await new Promise<void>((resolve) => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Expected API server to bind to a local port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
describe("CommandBoard API contracts", () => {
it("exposes health contract", async () => {
const response = await fetch(`${baseUrl}/health`);
const body = await response.json() as { ok: boolean; service: string };
expect(response.status).toBe(200);
expect(body).toEqual({ ok: true, service: "commandboard-api" });
});
it("exposes default plugin contract including sh1pt", async () => {
const response = await fetch(`${baseUrl}/api/plugins`);
const body = await response.json() as {
plugins: Array<{ id: string; enabled: boolean; capabilities: string[] }>;
capabilities: Record<string, string[]>;
};
expect(response.status).toBe(200);
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt"]);
expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({
enabled: true,
capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"])
});
expect(body.capabilities["actions.publish"]).toEqual(["sh1pt"]);
});
it("exposes sh1pt project and action contracts", async () => {
const projectsResponse = await fetch(`${baseUrl}/api/plugins/sh1pt/projects`);
const projectsBody = await projectsResponse.json() as { projects: Array<{ id: string; board: string; status: string; actions: number }> };
const actionsResponse = await fetch(`${baseUrl}/api/plugins/sh1pt/actions`);
const actionsBody = await actionsResponse.json() as { actions: Array<{ id: string; title: string; publishable: boolean }> };
expect(projectsResponse.status).toBe(200);
expect(projectsBody.projects[0]).toMatchObject({ id: "sh1pt_project_1", board: "/projects/sh1pt", status: "active" });
expect(actionsResponse.status).toBe(200);
expect(actionsBody.actions[0]).toMatchObject({ id: "action_release_checklist", publishable: true });
});
it("accepts sh1pt action publish requests", async () => {
const response = await fetch(`${baseUrl}/api/plugins/sh1pt/actions/publish`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action_id: "action_release_checklist" })
});
const body = await response.json() as { accepted: boolean; action_id: string; board: string };
expect(response.status).toBe(202);
expect(body).toEqual({
accepted: true,
action_id: "action_release_checklist",
board: "/projects/sh1pt"
});
});
it("rejects invalid LogicSRC task payloads", async () => {
const response = await fetch(`${baseUrl}/api/tasks`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "logicsrc.task", title: "Incomplete" })
});
const body = await response.json() as { errors: unknown[] };
expect(response.status).toBe(422);
expect(Array.isArray(body.errors)).toBe(true);
});
});

View file

@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { pathToFileURL } from "node:url";
import { createPluginRegistry } from "@logicsrc/plugin-core";
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
import { sh1ptPlugin } from "@logicsrc/plugin-sh1pt";
@ -40,13 +41,15 @@ const sh1ptActions = [
{ id: "action_deploy_preview", title: "Deploy preview", publishable: true }
];
const server = createServer(async (request, response) => {
try {
await route(request, response);
} catch (error) {
json(response, 500, { error: error instanceof Error ? error.message : String(error) });
}
});
export function createCommandBoardServer() {
return createServer(async (request, response) => {
try {
await route(request, response);
} catch (error) {
json(response, 500, { error: error instanceof Error ? error.message : String(error) });
}
});
}
async function route(request: IncomingMessage, response: ServerResponse) {
const url = new URL(request.url ?? "/", "http://localhost");
@ -141,7 +144,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const port = Number(process.env.PORT ?? 4010);
server.listen(port, () => {
console.log(`CommandBoard.run API listening on http://localhost:${port}`);
});
export function startCommandBoardServer(port = Number(process.env.PORT ?? 4010)) {
const server = createCommandBoardServer();
server.listen(port, () => {
console.log(`CommandBoard.run API listening on http://localhost:${port}`);
});
return server;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
startCommandBoardServer();
}

View file

@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
test.describe("CommandBoard.run PWA", () => {
test("renders the command board workspace", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("CommandBoard.run").first()).toBeVisible();
await expect(page.getByRole("heading", { name: "The command network for humans and AI agents." })).toBeVisible();
await expect(page.getByText("/gigs", { exact: true })).toBeVisible();
await expect(page.getByText("QA checkout flow")).toBeVisible();
});
test("shows default plugin status including sh1pt", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("CoinPay").last()).toBeVisible();
await expect(page.getByText("uGig").last()).toBeVisible();
await expect(page.getByText("sh1pt").last()).toBeVisible();
await expect(page.getByText("projects, actions, and releases")).toBeVisible();
});
test("surfaces sh1pt project activity", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("/projects/sh1pt", { exact: true })).toBeVisible();
await expect(page.getByText("Release action published")).toBeVisible();
await expect(page.getByText("/projects/sh1pt · deployment ready")).toBeVisible();
});
});

View file

@ -5,12 +5,15 @@
"type": "module",
"scripts": {
"build": "node scripts/check-assets.js",
"dev": "vite --host 0.0.0.0"
"dev": "vite --host 0.0.0.0",
"test:e2e": "playwright test"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.6",
"typescript": "^5.9.3"
},
"devDependencies": {}
"devDependencies": {
"@playwright/test": "^1.57.0"
}
}

View file

@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: {
timeout: 5_000
},
fullyParallel: true,
reporter: process.env.CI ? [["github"], ["list"]] : "list",
use: {
baseURL: "http://127.0.0.1:5173",
trace: "on-first-retry"
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] }
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] }
}
],
webServer: {
command: "npm run dev -- --port 5173",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
timeout: 30_000
}
});