From 254233c3ab1d6167858fe952076f225455dbd6e4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 6 Jun 2026 11:30:12 +0000 Subject: [PATCH] Add contract and Playwright PR checks --- .github/workflows/ci.yml | 36 ++++---- .github/workflows/test.yml | 16 +--- .gitignore | 2 + apps/commandboard-api/package.json | 7 +- apps/commandboard-api/src/contract.test.ts | 88 +++++++++++++++++++ apps/commandboard-api/src/index.ts | 32 ++++--- .../commandboard-web/e2e/commandboard.spec.ts | 29 ++++++ apps/commandboard-web/package.json | 7 +- apps/commandboard-web/playwright.config.ts | 31 +++++++ package-lock.json | 70 ++++++++++++++- package.json | 4 +- 11 files changed, 277 insertions(+), 45 deletions(-) create mode 100644 apps/commandboard-api/src/contract.test.ts create mode 100644 apps/commandboard-web/e2e/commandboard.spec.ts create mode 100644 apps/commandboard-web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b6cb1..0b5ec27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,3 @@ -# Managed by sh1pt Actions Fleet -# pack: node-pnpm-ci@1.0.0 -# install: sh1pt-actions-store -# hash: sha256:aa7aeaf3bddaf7bf324ceb02f46ab421bb229d5f5917fdb6f7a1a948a4a9fcce name: CI on: @@ -19,21 +15,31 @@ concurrency: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: - version: 9 + node-version: 24 + cache: npm - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: pnpm + - name: Install dependencies + run: npm ci - - run: pnpm install --frozen-lockfile + - name: Build and unit tests + run: npm run check - - run: pnpm typecheck + - name: Validate schema fixtures + run: npm run schemas:validate - - run: pnpm test + - name: API contract tests + run: npm run test:contract + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Playwright E2E + run: npm run test:e2e diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed6aa73..6ab5961 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,3 @@ -# Managed by sh1pt Actions Fleet -# pack: node-pnpm-test@1.0.0 -# install: sh1pt-actions-store -# hash: sha256:14a3f6fdbc21be92e815a16317251c967e4fbd95a5daf98819e0c23e7d56bf4f name: test on: @@ -19,17 +15,13 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 9.12.0 - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: pnpm + node-version: 24 + cache: npm - - run: pnpm install --frozen-lockfile + - run: npm ci - - run: pnpm test + - run: npm test env: CI: true diff --git a/.gitignore b/.gitignore index 84ef391..eb2f1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ dist !.env.example .DS_Store coverage +playwright-report +test-results *.tsbuildinfo .commandboard diff --git a/apps/commandboard-api/package.json b/apps/commandboard-api/package.json index d606a85..7f13c14 100644 --- a/apps/commandboard-api/package.json +++ b/apps/commandboard-api/package.json @@ -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" } } diff --git a/apps/commandboard-api/src/contract.test.ts b/apps/commandboard-api/src/contract.test.ts new file mode 100644 index 0000000..ba3d3d1 --- /dev/null +++ b/apps/commandboard-api/src/contract.test.ts @@ -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((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((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; + }; + + 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); + }); +}); diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index 8db4b1a..88f541f 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -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 { 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(); +} diff --git a/apps/commandboard-web/e2e/commandboard.spec.ts b/apps/commandboard-web/e2e/commandboard.spec.ts new file mode 100644 index 0000000..c8a1bfe --- /dev/null +++ b/apps/commandboard-web/e2e/commandboard.spec.ts @@ -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(); + }); +}); diff --git a/apps/commandboard-web/package.json b/apps/commandboard-web/package.json index 7e4e82a..44541d4 100644 --- a/apps/commandboard-web/package.json +++ b/apps/commandboard-web/package.json @@ -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" + } } diff --git a/apps/commandboard-web/playwright.config.ts b/apps/commandboard-web/playwright.config.ts new file mode 100644 index 0000000..28ba9dd --- /dev/null +++ b/apps/commandboard-web/playwright.config.ts @@ -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 + } +}); diff --git a/package-lock.json b/package-lock.json index 79d669b..284c128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "@logicsrc/validators": "file:../../packages/validators" }, "devDependencies": { - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "vitest": "^4.0.8" } }, "apps/commandboard-web": { @@ -41,7 +42,9 @@ "typescript": "^5.9.3", "vite": "^7.2.6" }, - "devDependencies": {} + "devDependencies": { + "@playwright/test": "^1.57.0" + } }, "apps/commandboard-web/node_modules/vite": { "version": "7.3.5", @@ -939,6 +942,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -2486,6 +2505,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index 5e5fb97..1a51ef9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", - "schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures" + "schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures", + "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract", + "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e" }, "devDependencies": { "@types/node": "^24.10.1",