diff --git a/.github/workflows/deploy-mindmap-pages.yml b/.github/workflows/deploy-mindmap-pages.yml new file mode 100644 index 00000000..3f6254b0 --- /dev/null +++ b/.github/workflows/deploy-mindmap-pages.yml @@ -0,0 +1,64 @@ +name: Deploy mindmap to GitHub Pages + +on: + pull_request: + paths: + - "prototype-mindmap/**" + - ".github/workflows/deploy-mindmap-pages.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build Pages artifact + runs-on: ubuntu-latest + defaults: + run: + working-directory: prototype-mindmap + steps: + - name: Require main for a deployment + if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' }} + working-directory: . + run: | + echo "Mindmap Pages deployments must run from main." >&2 + exit 1 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: prototype-mindmap/package-lock.json + - run: npm ci + - name: Production build + run: npm run build:pages + - name: Verify Pages artifact + run: npm run verify:pages + - name: Configure Pages + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + uses: actions/configure-pages@v5 + - name: Upload Pages artifact + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + uses: actions/upload-pages-artifact@v4 + with: + path: prototype-mindmap/dist + + deploy: + name: Deploy Pages artifact + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }} + needs: build + runs-on: ubuntu-latest + concurrency: + group: mindmap-pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/mindmap-tests.yml b/.github/workflows/mindmap-tests.yml index 7f12698d..5a8eb672 100644 --- a/.github/workflows/mindmap-tests.yml +++ b/.github/workflows/mindmap-tests.yml @@ -15,6 +15,7 @@ on: - "backend/src/__tests__/openaiProxy.test.ts" - "scripts/get_env.py" - "scripts/test_get_env.py" + - ".github/workflows/deploy-mindmap-pages.yml" - ".github/workflows/mindmap-tests.yml" push: branches: [main, mindmap-main] @@ -27,6 +28,7 @@ on: - "backend/src/openaiProxy.ts" - "backend/src/toolGrants.ts" - "scripts/get_env.py" + - ".github/workflows/deploy-mindmap-pages.yml" - ".github/workflows/mindmap-tests.yml" permissions: @@ -79,19 +81,22 @@ jobs: - name: Eval typecheck run: npm run eval:typecheck - name: Production build - env: - VITE_BACKEND_URL: https://ci.invalid/api - VITE_REQUIRE_LAUNCH: "true" - run: npm run build + run: npm run build:pages + - name: Verify Pages artifact + run: npm run verify:pages - name: Install Chromium run: npx playwright install --with-deps chromium - - name: Playwright mocked smoke + - name: Playwright development smoke run: npm run test:e2e + - name: Playwright production artifact smoke + run: npm run test:e2e:pages:built - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v6 with: name: mindmap-playwright-report - path: prototype-mindmap/playwright-report + path: | + prototype-mindmap/playwright-report + prototype-mindmap/playwright-report-pages if-no-files-found: ignore retention-days: 7 diff --git a/.gitignore b/.gitignore index ae2136e1..f8c08aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ MANIFEST # Environments .env* +!prototype-mindmap/.env.production .venv env/ venv/ diff --git a/prototype-mindmap/.env.production b/prototype-mindmap/.env.production new file mode 100644 index 00000000..b3b155fe --- /dev/null +++ b/prototype-mindmap/.env.production @@ -0,0 +1,2 @@ +VITE_BACKEND_URL=https://app.thoughtful-ai.com/api +VITE_REQUIRE_LAUNCH=true diff --git a/prototype-mindmap/.gitignore b/prototype-mindmap/.gitignore index fc09212b..195efa95 100644 --- a/prototype-mindmap/.gitignore +++ b/prototype-mindmap/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +playwright-report-pages/ +test-results/ *.log tsconfig.tsbuildinfo eval/runs/ diff --git a/prototype-mindmap/README.md b/prototype-mindmap/README.md index 2bfdee12..1fda76ae 100644 --- a/prototype-mindmap/README.md +++ b/prototype-mindmap/README.md @@ -28,9 +28,10 @@ is ignored when `PROD` is set, so a misconfigured deploy environment cannot ship ungated bundle. A grant present in the URL is processed in every mode. `VITE_BACKEND_URL` is **required** for production builds and throws at startup if -missing. Development and test builds fall back to `http://localhost:8000/api`; a -production bundle carrying that fallback would point every user's browser at their -own machine. +missing. The committed `.env.production` contains the public Pages backend and +launch requirement; Vite loads it automatically on production builds. +Development and test builds fall back to `http://localhost:8000/api`; a production +bundle carrying that fallback would point every user's browser at their own machine. The Writing Tools registry uses `VITE_MINDMAP_TOOL_URL`. Its development default is `http://localhost:5181/`; its production default is @@ -38,6 +39,75 @@ is `http://localhost:5181/`; its production default is continues to use Vite's development server at port 4173, so production-gate verification remains a separate build check. +## GitHub Pages production build + +GitHub Pages hosts only the compiled browser files. Authentication, launch +grants, document snapshots, OpenAI proxying, and token persistence remain on +the Writing Tools backend. The Pages build receives public configuration only; +never provide an OpenAI key, Better Auth secret, database credential, or +`wtk_` token to this build. + +Build and verify the exact production artifact with: + +```sh +npm run build:pages +npm run verify:pages +npm run test:e2e:pages +``` + +The normal `test:e2e:pages` command rebuilds and verifies first, so it cannot +silently exercise a stale `dist`; CI uses `test:e2e:pages:built` after its +existing production build. `verify:pages` requires an HTTPS backend, checks that +`dist/index.html` and the configured backend are present, and refuses published +`.env` files. The source +retains its intentional development fallback string, so the production +Playwright suite—not a raw string scan—proves that the built app actually sends +exchange and provider requests to the configured HTTPS backend. It serves that +same `dist` directory with `vite preview`; the normal `test:e2e` command +continues to exercise the development server. + +Every supported entry point is the site root with launch state in the URL +fragment. Fragments are handled entirely in the browser and are not sent to +GitHub Pages, so this build has no path-based SPA route and needs no `404.html` +fallback. + +The workflow at `.github/workflows/deploy-mindmap-pages.yml` builds on relevant +pull requests but cannot publish them. A deployment is a manual workflow run +from `main`; selecting any other ref fails without uploading an artifact. +Running the workflow does not configure the repository's Pages settings, DNS, +the production backend, or the Writing Tools tile. + +Before the first deployment: + +1. Merge and deploy the grant-origin work from + [#579](https://github.com/AIToolsLab/writing-tools/pull/579), refresh the + Pages branch from `main`, and unskip the `wt_api` production smoke. It must + prove that exchange and provider requests use the launching platform's API, + not the build-time fallback. +2. Land the tool/device-client split and confirm production allowlists contain + the exact compiled taskpane client plus `mindmap`. +3. Configure this repository's Pages source as **GitHub Actions** and claim + `mindmap.thoughtful-ai.com`. +4. Ask the DNS owner to create exactly: + + ```text + CNAME mindmap.thoughtful-ai.com → aitoolslab.github.io + ``` + + Do not append `/writing-tools` or any repository name. DNS cannot carry a + URL path; GitHub selects the repository from the claimed custom domain and + the request host. +5. Wait for DNS and TLS provisioning, then enable **Enforce HTTPS**. GitHub + documents that DNS propagation and availability of HTTPS enforcement can + each take up to 24 hours, so an initially unavailable HTTPS launch is not + by itself an application failure. +6. Run the real Word and Google Docs off-origin checks before exposing the + tile. + +Tile rollout belongs to the separate Writing Tools add-in build. +`VITE_ENABLE_MINDMAP_TOOL` is read by +`frontend/src/pages/tools/index.tsx`; it is not part of this Pages build. + ## Design principle: typed proposals with deterministic consequences - **Enforcement (code, not configurable):** a mirror must pass validation before diff --git a/prototype-mindmap/e2e-pages/global-setup.ts b/prototype-mindmap/e2e-pages/global-setup.ts new file mode 100644 index 00000000..03575c98 --- /dev/null +++ b/prototype-mindmap/e2e-pages/global-setup.ts @@ -0,0 +1,21 @@ +import { fileURLToPath } from "node:url"; +import { preview } from "vite"; + +export default async function startPagesPreview(): Promise<(() => Promise) | undefined> { + if (process.env.PLAYWRIGHT_SKIP_SERVER) return undefined; + + const port = Number(process.env.PLAYWRIGHT_PAGES_PORT ?? 4174); + const root = fileURLToPath(new URL("..", import.meta.url)); + const server = await preview({ + root, + preview: { + host: "127.0.0.1", + port, + strictPort: true, + }, + }); + + return async () => { + await server.close(); + }; +} diff --git a/prototype-mindmap/e2e-pages/pages-smoke.spec.ts b/prototype-mindmap/e2e-pages/pages-smoke.spec.ts new file mode 100644 index 00000000..65751a30 --- /dev/null +++ b/prototype-mindmap/e2e-pages/pages-smoke.spec.ts @@ -0,0 +1,159 @@ +import { expect, test } from "@playwright/test"; + +const PRODUCTION_API = "https://app.thoughtful-ai.com/api"; + +function envelope(response: unknown) { + return { choices: [{ message: { content: JSON.stringify(response) } }] }; +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); +}); + +test("the production artifact blocks a direct visit", async ({ page }) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const failedAssets: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("response", (response) => { + if (response.url().startsWith("http://127.0.0.1:") && response.status() >= 400) { + failedAssets.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto("/"); + + await expect(page.getByRole("heading", { + name: "Launch Mindmap from Writing Tools", + })).toBeVisible(); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(failedAssets).toEqual([]); +}); + +test("the production artifact exchanges a grant and authenticates AI calls", async ({ + page, +}) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const failedAssets: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("response", (response) => { + if (response.url().startsWith("http://127.0.0.1:") && response.status() >= 400) { + failedAssets.push(`${response.status()} ${response.url()}`); + } + }); + + await page.route("**/handoff/exchange", async (route) => { + expect(route.request().url()).toBe(`${PRODUCTION_API}/handoff/exchange`); + expect(route.request().postDataJSON()).toEqual({ grant_id: "pages-grant" }); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + access_token: "wtk_pages", + expires_in: 3600, + client_id: "mindmap", + scopes: ["openai:chat", "doc:read"], + doc: { + documentLabel: "Pages Essay.docx", + beforeCursor: "A production ", + selectedText: "snapshot", + afterCursor: " for testing.", + }, + }), + }); + }); + await page.route("**/openai/chat/completions", async (route) => { + expect(route.request().url()).toBe(`${PRODUCTION_API}/openai/chat/completions`); + expect(route.request().headers().authorization).toBe("Bearer wtk_pages"); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify(envelope({ + response: { + kind: "question", + text: "What matters in this production snapshot?", + stance: "deepen", + }, + })), + }); + }); + + await page.goto("/#wt_grant=pages-grant"); + await expect(page.getByText(/Snapshot of Pages Essay\.docx captured at launch/)).toBeVisible(); + await expect(page.locator(".draft-editor")).toContainText( + "A production snapshot for testing.", + ); + await expect.poll(() => new URL(page.url()).hash).toBe(""); + + const composer = page.locator("textarea.composer-textarea"); + await composer.fill("Help me think about this."); + await composer.press("Enter"); + await expect(page.getByText("What matters in this production snapshot?")).toBeVisible(); + + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(failedAssets).toEqual([]); +}); + +// Unskip after #579 merges — depends on the wt_api launch schema. +test.skip("a launch uses the issuing platform API instead of the build-time fallback", async ({ + page, +}) => { + const launchApi = "https://platform.example/api"; + let fallbackRequests = 0; + page.on("request", (request) => { + if (request.url().startsWith(`${PRODUCTION_API}/`)) fallbackRequests += 1; + }); + await page.route("**/handoff/exchange", async (route) => { + expect(route.request().url()).toBe(`${launchApi}/handoff/exchange`); + expect(route.request().postDataJSON()).toEqual({ grant_id: "issuing-platform-grant" }); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + access_token: "wtk_issuing_platform", + expires_in: 3600, + client_id: "mindmap", + scopes: ["openai:chat"], + doc: null, + }), + }); + }); + await page.route("**/openai/chat/completions", async (route) => { + expect(route.request().url()).toBe(`${launchApi}/openai/chat/completions`); + expect(route.request().headers().authorization).toBe("Bearer wtk_issuing_platform"); + await route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify(envelope({ + response: { + kind: "question", + text: "This call used the issuing platform.", + stance: "deepen", + }, + })), + }); + }); + + const launchHash = new URLSearchParams({ + wt_grant: "issuing-platform-grant", + wt_api: launchApi, + }); + await page.goto(`/#${launchHash.toString()}`); + const composer = page.locator("textarea.composer-textarea"); + await composer.fill("Use the platform that launched this page."); + await composer.press("Enter"); + await expect(page.getByText("This call used the issuing platform.")).toBeVisible(); + expect(fallbackRequests).toBe(0); +}); diff --git a/prototype-mindmap/e2e/global-setup.ts b/prototype-mindmap/e2e/global-setup.ts new file mode 100644 index 00000000..0eea014c --- /dev/null +++ b/prototype-mindmap/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +export default async function startDevelopmentServer(): Promise< + (() => Promise) | undefined +> { + if (process.env.PLAYWRIGHT_SKIP_SERVER) return undefined; + + const port = Number(process.env.PLAYWRIGHT_PORT ?? 4173); + const root = fileURLToPath(new URL("..", import.meta.url)); + const server = await createServer({ + root, + server: { + host: "127.0.0.1", + port, + strictPort: true, + open: false, + }, + }); + await server.listen(); + + return async () => { + await server.close(); + }; +} diff --git a/prototype-mindmap/package.json b/prototype-mindmap/package.json index d3efdb1d..34e3f89d 100644 --- a/prototype-mindmap/package.json +++ b/prototype-mindmap/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:pages": "npm run build", + "verify:pages": "node scripts/verify-pages-build.mjs", "preview": "vite preview", "test": "vitest run", "eval": "tsx eval/run.ts", @@ -14,6 +16,8 @@ "eval:typecheck": "tsc -p tsconfig.eval.json", "eval:report": "tsx eval/report.ts", "test:e2e": "playwright test", + "test:e2e:pages": "npm run build:pages && npm run verify:pages && npm run test:e2e:pages:built", + "test:e2e:pages:built": "playwright test --config playwright.pages.config.ts", "test:watch": "vitest" }, "dependencies": { diff --git a/prototype-mindmap/playwright.config.ts b/prototype-mindmap/playwright.config.ts index c91d311e..f792b5e6 100644 --- a/prototype-mindmap/playwright.config.ts +++ b/prototype-mindmap/playwright.config.ts @@ -1,21 +1,10 @@ import { defineConfig } from "@playwright/test"; const port = Number(process.env.PLAYWRIGHT_PORT ?? 4173); -const viteCommand = - process.platform === "win32" - ? `set PORT=${port}&& node node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}` - : `PORT=${port} node node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}`; export default defineConfig({ testDir: "./e2e", timeout: 30_000, + globalSetup: "./e2e/global-setup.ts", use: { baseURL: `http://127.0.0.1:${port}`, headless: true }, - webServer: process.env.PLAYWRIGHT_SKIP_SERVER ? undefined : { - // Keep this on Vite's development server: launcher enforcement is intentionally - // off here. The production build is compiled separately in CI. - command: viteCommand, - url: `http://127.0.0.1:${port}`, - reuseExistingServer: !process.env.CI, - timeout: 30_000, - }, }); diff --git a/prototype-mindmap/playwright.pages.config.ts b/prototype-mindmap/playwright.pages.config.ts new file mode 100644 index 00000000..2426b2a7 --- /dev/null +++ b/prototype-mindmap/playwright.pages.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; + +const port = Number(process.env.PLAYWRIGHT_PAGES_PORT ?? 4174); + +export default defineConfig({ + testDir: "./e2e-pages", + timeout: 30_000, + outputDir: "test-results/pages", + reporter: [["html", { outputFolder: "playwright-report-pages", open: "never" }]], + globalSetup: "./e2e-pages/global-setup.ts", + use: { baseURL: `http://127.0.0.1:${port}`, headless: true }, +}); diff --git a/prototype-mindmap/scripts/verify-pages-build.mjs b/prototype-mindmap/scripts/verify-pages-build.mjs new file mode 100644 index 00000000..8873cfd7 --- /dev/null +++ b/prototype-mindmap/scripts/verify-pages-build.mjs @@ -0,0 +1,66 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; + +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const distRoot = join(projectRoot, "dist"); +const productionEnv = loadEnv("production", projectRoot, "VITE_"); +const expectedBackendUrl = productionEnv.VITE_BACKEND_URL?.trim() ?? ""; +const searchableExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt"]); + +function fail(message) { + throw new Error(`Pages artifact verification failed: ${message}`); +} + +function validateBackendUrl(raw) { + if (!raw) fail("VITE_BACKEND_URL is missing from .env.production."); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail(`VITE_BACKEND_URL is not a valid absolute URL: ${raw}`); + } + if (parsed.protocol !== "https:") { + fail(`VITE_BACKEND_URL must use HTTPS, received: ${raw}`); + } +} + +async function filesBelow(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await filesBelow(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +validateBackendUrl(expectedBackendUrl); + +const indexPath = join(distRoot, "index.html"); +if (!(await stat(indexPath).catch(() => null))?.isFile()) { + fail("dist/index.html is missing."); +} + +const files = await filesBelow(distRoot); +const environmentFiles = files.filter((path) => { + const name = path.slice(distRoot.length + 1).split(/[\\/]/).at(-1) ?? ""; + return name === ".env" || name.startsWith(".env."); +}); +if (environmentFiles.length > 0) { + fail(`environment files were published: ${environmentFiles.join(", ")}`); +} + +let compiledText = ""; +for (const path of files) { + if (!searchableExtensions.has(extname(path).toLowerCase())) continue; + compiledText += await readFile(path, "utf8"); +} + +if (!compiledText.includes(expectedBackendUrl)) { + fail(`the compiled output does not contain ${expectedBackendUrl}.`); +} + +console.log(`Pages artifact verified for ${expectedBackendUrl}.`); diff --git a/prototype-mindmap/vite.config.ts b/prototype-mindmap/vite.config.ts index 6831564d..1b212384 100644 --- a/prototype-mindmap/vite.config.ts +++ b/prototype-mindmap/vite.config.ts @@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { - exclude: ["e2e/**", "node_modules/**", "dist/**"], + exclude: ["e2e/**", "e2e-pages/**", "node_modules/**", "dist/**"], }, server: { // Honor a PORT override (used by preview tooling that assigns a free port);