diff --git a/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts new file mode 100644 index 0000000..77f6ca2 --- /dev/null +++ b/apps/logicsrc-web/contract/logicsrc-web.contract.test.ts @@ -0,0 +1,75 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { accessSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +let server: ChildProcessWithoutNullStreams; +const port = 4291; +const baseUrl = `http://127.0.0.1:${port}`; + +beforeAll(async () => { + accessSync(new URL("../dist/index.html", import.meta.url)); + server = spawn(process.execPath, ["server.js"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, PORT: String(port) } + }); + + await waitForServer(); +}); + +afterAll(() => { + server?.kill(); +}); + +describe("LogicSRC web contracts", () => { + it("serves SPA routes from the built app shell", async () => { + for (const route of ["/", "/openspec", "/docs", "/blog", "/about", "/terms", "/privacy"]) { + const response = await fetch(`${baseUrl}${route}`); + const text = await response.text(); + + expect(response.status, route).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + expect(text).toContain('
'); + } + }); + + it("serves sitemap.xml as XML with canonical routes", async () => { + const response = await fetch(`${baseUrl}/sitemap.xml`); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/xml"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(text).toContain("https://logicsrc.com/openspec"); + expect(text).toContain("https://logicsrc.com/blog"); + }); + + it("serves blog/rss.xml as RSS XML", async () => { + const response = await fetch(`${baseUrl}/blog/rss.xml`); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/xml"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(text).toContain("LogicSRC OpenSpec Compatibility"); + }); +}); + +async function waitForServer() { + let lastError: unknown; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) { + return; + } + } catch (error) { + lastError = error; + } + + await delay(250); + } + + throw new Error(`LogicSRC web server did not start: ${String(lastError)}`); +} diff --git a/apps/logicsrc-web/e2e/logicsrc.spec.ts b/apps/logicsrc-web/e2e/logicsrc.spec.ts new file mode 100644 index 0000000..09d974a --- /dev/null +++ b/apps/logicsrc-web/e2e/logicsrc.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +test.describe("LogicSRC PWA", () => { + test("renders OpenSpec comparison and compatibility commands", async ({ page }) => { + await page.goto("/openspec"); + + await expect(page.getByRole("heading", { name: "LogicSRC", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "LogicSRC vs OpenSpec.dev" })).toBeVisible(); + await expect(page.getByText("OpenSpec.dev currently positions itself as no-MCP.")).toBeVisible(); + await expect(page.getByText("logicsrc --openspec agentswarm --yolo")).toBeVisible(); + await expect(page.getByText("openspec import")).toBeVisible(); + await expect(page.getByText("openspec export")).toBeVisible(); + }); + + test("renders top-level docs and legal route targets", async ({ page }) => { + await page.goto("/privacy"); + + await expect(page.getByRole("heading", { name: "Top-Level Pages" })).toBeVisible(); + await expect(page.getByText("/docs · Docs")).toBeVisible(); + await expect(page.getByText("/blog · Blog")).toBeVisible(); + await expect(page.getByText("/terms · Terms")).toBeVisible(); + await expect(page.getByText("/privacy · Privacy")).toBeVisible(); + }); + + test("serves sitemap and RSS XML endpoints", async ({ request }) => { + const sitemap = await request.get("/sitemap.xml"); + const rss = await request.get("/blog/rss.xml"); + + expect(sitemap.status()).toBe(200); + expect(sitemap.headers()["content-type"]).toMatch(/(?:application|text)\/xml/); + await expect(sitemap.text()).resolves.toContain("https://logicsrc.com/openspec"); + + expect(rss.status()).toBe(200); + expect(rss.headers()["content-type"]).toMatch(/(?:application|text)\/xml/); + await expect(rss.text()).resolves.toContain("LogicSRC OpenSpec Compatibility"); + }); +}); diff --git a/apps/logicsrc-web/package.json b/apps/logicsrc-web/package.json index 9f6f430..c56e0d6 100644 --- a/apps/logicsrc-web/package.json +++ b/apps/logicsrc-web/package.json @@ -6,11 +6,18 @@ "scripts": { "build": "node scripts/check-assets.js && vite build", "dev": "vite --host 0.0.0.0", - "start": "node server.js" + "start": "node server.js", + "test": "vitest run contract", + "test:contract": "vitest run contract", + "test:e2e": "playwright test" }, "dependencies": { "@logicsrc/commandboard-api": "file:../commandboard-api", "typescript": "^5.9.3", "vite": "^7.2.6" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "vitest": "^4.0.8" } } diff --git a/apps/logicsrc-web/playwright.config.ts b/apps/logicsrc-web/playwright.config.ts new file mode 100644 index 0000000..e85e8b4 --- /dev/null +++ b/apps/logicsrc-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:5174", + trace: "on-first-retry" + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] } + }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 7"] } + } + ], + webServer: { + command: "npm run dev -- --port 5174", + url: "http://127.0.0.1:5174", + reuseExistingServer: !process.env.CI, + timeout: 30_000 + } +}); diff --git a/package-lock.json b/package-lock.json index bb40e33..22edd74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,10 @@ "@logicsrc/commandboard-api": "file:../commandboard-api", "typescript": "^5.9.3", "vite": "^7.2.6" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "vitest": "^4.0.8" } }, "apps/logicsrc-web/node_modules/vite": { diff --git a/package.json b/package.json index 81791f0..dd84515 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", "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" + "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract", + "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e" }, "devDependencies": { "@types/node": "^24.10.1",