Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Pages

# Deploys the web app (packages/targets/graph-web) to GitHub Pages as a project site, https://exadev.github.io/process-graph/. The repository's Pages source must be set to "GitHub Actions" once (`gh api repos/ExaDev/process-graph/pages -X POST -f build_type=workflow`); until it is, the deploy job fails at the deployment step.

on:
push:
branches: [main]

permissions:
contents: read

jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The app's base path is the repository name, so the build serves under /process-graph/ and its manifest scope, service worker scope and asset URLs all follow it. GRAPH_WEB_BASE is read by vite.config.ts and declared in the _build task's env so turbo passes it through and keys its cache on it.
- uses: ./.github/actions/setup-workspace
with:
task: pages
command: GRAPH_WEB_BASE="/${GITHUB_REPOSITORY#*/}/" pnpm exec turbo run _build --filter=@exadev/process-graph-web
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: packages/targets/graph-web/dist

deploy:
name: Deploy
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
# Two deploys never overlap and a running one is never cancelled: a cancelled deployment can leave the site half-published, so the next run queues behind it.
concurrency:
group: pages
cancel-in-progress: false
permissions:
pages: write # to publish the artifact
id-token: write # OIDC proof to the Pages service that the deploy comes from this workflow
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
Comment thread
Mearman marked this conversation as resolved.
Comment thread
Mearman marked this conversation as resolved.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ reports
test-results
playwright-report
dev-dist
# The sub-path build the web app's base-path end-to-end test serves.
dist-base-path

# Un-ignore AGENTS.md/CLAUDE.md against a machine-wide global gitignore that excludes them everywhere by default. README.md symlinked to AGENTS.md and CLAUDE.md is this repository's deliberate "one instruction file" pattern, so these symlinks must be tracked.
!AGENTS.md
Expand Down
10 changes: 9 additions & 1 deletion packages/targets/graph-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ A value the app cannot use is left out and named in a notice. A resumed sync rea

The store is graph-sqlite-wasm: SQLite in a dedicated worker on the origin private file system, shared by every tab under a Web Lock, with each tab hearing the others' writes. It needs no `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy` headers, and neither the dev server nor the preview server sets them.

The GitHub source runs in the page through the source contract's in-process oRPC client. The token is kept in the origin's local storage on this device. It is read when a sync starts, handed to the source's token port and sent only in the `Authorization` header of requests to `api.github.com`; it is never logged, shown or put in React state. The device also keeps each model's last sync record (its checkpoint and gaps) and the theme preference.
The GitHub source runs in the page through the source contract's in-process oRPC client. The app stores the token, each model's last sync record and the synced issues in the browser origin it is served from: the token and records in local storage, the issues in the origin private file system. Every page on that origin can read them, and on the GitHub Pages deployment that origin is `https://exadev.github.io`, shared by every project of the organisation (see Deployment). The token is kept in the origin's local storage on this device. It is read when a sync starts, handed to the source's token port and sent only in the `Authorization` header of requests to `api.github.com`; it is never logged, shown or put in React state. The device also keeps each model's last sync record (its checkpoint and gaps) and the theme preference.

## Offline and updates

Expand All @@ -59,6 +59,14 @@ The service worker precaches the app shell: the page, scripts, styles, icons and

`pnpm build` runs `vite build`. `build/icons.ts` renders the icons at build time: one SVG glyph in the theme contract's colours, rasterised by resvg to the 192 and 512 pixel PNGs, `any` and `maskable`, that Chromium's install criteria ask for, and emitted into the bundle, so no generated image is committed. The manifest's theme and background colours come from graph-ui's theme values. `GRAPH_WEB_BASE` sets the base path the app is served under, such as `/process-graph/`; the manifest's `start_url` and `scope` follow it.

## Deployment

`.github/workflows/pages.yml` builds the app on every push to `main` and deploys it to GitHub Pages as a project site, at `https://exadev.github.io/process-graph/`. The workflow sets `GRAPH_WEB_BASE` to `/<repository name>/`; without it the app builds for the root, which is what `vite preview`, the tests and any host serving the app at the top of its own origin use. Vite's `base` takes the value, and the manifest's `start_url` and `scope`, the service worker's scope and precache, the icons and the page's own asset links are all relative to it. `e2e/base-path.e2e.test.ts` builds the app with the base set and checks that its shell, manifest, service worker scope and icons are served under it and that nothing is requested outside it.

GitHub Pages serves every project of the organisation from the single origin `https://exadev.github.io`, so the deployed copy shares its browser storage with every other page on it. That is accepted because every site there belongs to the owner. Anyone forking the deployment onto an origin shared with sites they do not control should not persist a token there.

The repository's Pages source must be set to GitHub Actions once, by someone with admin rights: `gh api repos/ExaDev/process-graph/pages -X POST -f build_type=workflow`. Until then the deploy job fails.

## Tests

The screen tests render the app with Testing Library in happy-dom against the store contract's in-memory fake and the source contract's fake source, each through its oRPC client, laid out by a stub engine. `test/app.smoke.test.ts` checks the built `dist/`: the manifest, the rendered icons and the precache.
Expand Down
72 changes: 10 additions & 62 deletions packages/targets/graph-web/e2e/app.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,15 @@
import { type Request } from "@playwright/test";
import { fixtureToken, rateLimitedToken } from "./github-fixture.ts";
import {
expect,
test as base,
type Page,
type Request,
} from "@playwright/test";
import {
fixtureToken,
rateLimitedToken,
startGitHubFixture,
type GitHubFixture,
} from "./github-fixture.ts";

const githubApiHost = "api.github.com";

/** Every test gets its own fixture server, and its browser context's requests to GitHub's API are answered by it. Playwright forwards the request from the test process, so the browser's own request still goes to api.github.com. */
const test = base.extend<{ readonly github: GitHubFixture }>({
github: [
async ({ context }, use) => {
const fixture = await startGitHubFixture();
await context.route(`https://${githubApiHost}/**`, async (route) => {
const response = await route.fetch({ url: fixture.url });
await route.fulfill({ response });
});
await use(fixture);
await fixture.close();
},
{ auto: true },
],
});

/** An element's card on the canvas. */
const card = (page: Page, id: string) =>
page.locator(`.react-flow__node [data-element-id="${id}"]`);
card,
githubApiHost,
serviceWorkerActive,
syncFinished,
syncScopes,
test,
} from "./support.ts";

/** Saves a token, adds the scopes and starts their sync. */
const syncScopes = async (
page: Page,
token: string,
scopes: readonly string[],
): Promise<void> => {
await page.getByLabel("Token", { exact: true }).fill(token);
await page.getByRole("button", { name: "Save on this device" }).click();
// Each scope is added after the one before, as a person would add them.
await scopes.reduce(async (previous, scope) => {
await previous;
await page
.getByLabel("Add an organisation, user or repository")
.fill(scope);
await page.getByRole("button", { name: "Add", exact: true }).click();
}, Promise.resolve());
await page.getByRole("button", { name: "Sync", exact: true }).click();
};

const syncFinished = async (page: Page): Promise<void> => {
await expect(page.locator("[data-progress]")).toContainText("Sync finished");
};

/** Waits until a service worker is active for the page's scope, so a reload is served from its precache. */
const serviceWorkerActive = async (page: Page): Promise<void> => {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
};
import { expect } from "@playwright/test";

test("syncs organisations across their repositories, draws the work and lists what is ready and blocked", async ({
page,
Expand Down
31 changes: 31 additions & 0 deletions packages/targets/graph-web/e2e/base-path-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { build, preview } from "vite";
import { basePath, basePathOutDir, basePathPreviewPort } from "./base-path.ts";

const configFile = new URL("../vite.config.ts", import.meta.url).pathname;

/**
* Playwright's global setup: builds the app a second time with `GRAPH_WEB_BASE` set to the sub-path, into its own output directory, and previews it on its own port for the base-path end-to-end test. This is not a `webServer` entry because Playwright starts those before global setup, when the build does not exist yet. The variable is set on this process before Vite loads `vite.config.ts`, which reads it once.
* @returns The teardown, which stops the preview server.
*/
export default async function serveAtBasePath(): Promise<() => Promise<void>> {
process.env.GRAPH_WEB_BASE = basePath;
await build({
configFile,
logLevel: "warn",
build: { outDir: basePathOutDir, emptyOutDir: true },
});
const server = await preview({
configFile,
logLevel: "warn",
build: { outDir: basePathOutDir },
preview: {
host: "127.0.0.1",
port: basePathPreviewPort,
strictPort: true,
},
});

return async () => {
await server.close();
};
}
41 changes: 41 additions & 0 deletions packages/targets/graph-web/e2e/base-path.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
import { basePath } from "./base-path.ts";

// Runs against the build made with `GRAPH_WEB_BASE` set to a sub-path (see base-path-build.ts), served as a project site serves it, under the sub-path.

test("serves the shell, manifest and service worker under the base path and requests nothing outside it", async ({
page,
baseURL,
}) => {
const origin = new URL(baseURL ?? "").origin;
const paths: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.origin === origin) {
paths.push(url.pathname);
}
});

await page.goto(basePath);
await expect(page.getByLabel("Token", { exact: true })).toBeVisible();
const scope = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;

return registration.scope;
});
expect(scope).toBe(`${origin}${basePath}`);

const manifestLink = await page
.locator('link[rel="manifest"]')
.getAttribute("href");
expect(manifestLink).toBe(`${basePath}manifest.webmanifest`);
const manifest: unknown = await (
await page.request.get(`${basePath}manifest.webmanifest`)
).json();
expect(manifest).toMatchObject({ start_url: basePath, scope: basePath });
const icon = await page.request.get(`${basePath}icons/maskable-512.png`);
expect(icon.headers()["content-type"]).toBe("image/png");

expect(paths).not.toHaveLength(0);
expect(paths.filter((path) => !path.startsWith(basePath))).toEqual([]);
});
8 changes: 8 additions & 0 deletions packages/targets/graph-web/e2e/base-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** The sub-path a project site serves the app under (`https://<owner>.github.io/process-graph/`), which the base-path build and its tests use. */
export const basePath = "/process-graph/";

/** Where the base-path build is written, beside `dist/` so it never disturbs the root build the other end-to-end tests serve. */
export const basePathOutDir = "dist-base-path";

/** The port the base-path build is previewed on, pinned with `--strictPort` like the root build's. */
export const basePathPreviewPort = 4174;
61 changes: 61 additions & 0 deletions packages/targets/graph-web/e2e/support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
expect,
test as base,
type Locator,
type Page,
} from "@playwright/test";
import { startGitHubFixture, type GitHubFixture } from "./github-fixture.ts";

export const githubApiHost = "api.github.com";

// What the end-to-end tests that run the app against the GitHub fixture share.

/** Every test gets its own fixture server, and its browser context's requests to GitHub's API are answered by it. Playwright forwards the request from the test process, so the browser's own request still goes to api.github.com. */
export const test = base.extend<{ readonly github: GitHubFixture }>({
github: [
async ({ context }, use) => {
const fixture = await startGitHubFixture();
await context.route(`https://${githubApiHost}/**`, async (route) => {
const response = await route.fetch({ url: fixture.url });
await route.fulfill({ response });
});
await use(fixture);
await fixture.close();
},
{ auto: true },
],
});

/** An element's card on the canvas. */
export const card = (page: Page, id: string): Locator =>
page.locator(`.react-flow__node [data-element-id="${id}"]`);

/** Saves a token, adds the scopes and starts their sync. */
export const syncScopes = async (
page: Page,
token: string,
scopes: readonly string[],
): Promise<void> => {
await page.getByLabel("Token", { exact: true }).fill(token);
await page.getByRole("button", { name: "Save on this device" }).click();
// Each scope is added after the one before, as a person would add them.
await scopes.reduce(async (previous, scope) => {
await previous;
await page
.getByLabel("Add an organisation, user or repository")
.fill(scope);
await page.getByRole("button", { name: "Add", exact: true }).click();
}, Promise.resolve());
await page.getByRole("button", { name: "Sync", exact: true }).click();
};

export const syncFinished = async (page: Page): Promise<void> => {
await expect(page.locator("[data-progress]")).toContainText("Sync finished");
};

/** Waits until a service worker is active for the page's scope, so a reload is served from its precache. */
export const serviceWorkerActive = async (page: Page): Promise<void> => {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
};
7 changes: 6 additions & 1 deletion packages/targets/graph-web/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export default defineConfig(
isomorphic: true,
browserGlobals: true,
react: { version },
additionalIgnores: ["dev-dist", "playwright-report", "test-results"],
additionalIgnores: [
"dev-dist",
"dist-base-path",
"playwright-report",
"test-results",
],
}),
);
18 changes: 17 additions & 1 deletion packages/targets/graph-web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { defineConfig } from "@playwright/test";
import { basePathPreviewPort } from "./e2e/base-path.ts";

/** Vite's own default port for `vite preview`, pinned with `--strictPort` so the tests never drive another server that happens to hold it. */
const previewPort = 4173;
const baseURL = `http://127.0.0.1:${String(previewPort)}`;
const basePathURL = `http://127.0.0.1:${String(basePathPreviewPort)}`;

/**
* End-to-end tests against the production build, served by `vite preview` as a static host would serve it, with no cross-origin isolation headers. The build itself comes from the `_build` task that `_test:e2e` depends on. Chromium is the browser CI installs for graph-sqlite-wasm's browser tests, at the version this Playwright expects. GitHub's API is answered by a local fixture server the tests start, reached by routing the browser's requests to api.github.com, so the browser still addresses GitHub and the token checks see the real request.
*
* A second build, made in global setup with the base set to a sub-path and previewed on its own port, is what `base-path.e2e.test.ts` runs against: the same app as a project site serves it.
*
* `--host 127.0.0.1`: Vite binds `localhost`, which can resolve to `::1` only, where the IPv4 base URL would be refused.
*/
export default defineConfig({
Expand All @@ -15,10 +19,22 @@ export default defineConfig({
reporter: "list",
// A fixed locale and time zone, so dates the app formats read the same on every machine.
use: { baseURL, locale: "en-GB", timezoneId: "UTC" },
globalSetup: "./e2e/base-path-build.ts",
webServer: {
command: `pnpm exec vite preview --host 127.0.0.1 --port ${String(previewPort)} --strictPort`,
url: baseURL,
reuseExistingServer: false,
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
projects: [
{
name: "chromium",
testIgnore: "base-path.e2e.test.ts",
use: { browserName: "chromium" },
},
{
name: "base-path",
testMatch: "base-path.e2e.test.ts",
use: { browserName: "chromium", baseURL: basePathURL },
},
],
});
1 change: 1 addition & 0 deletions packages/targets/graph-web/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"extends": ["//"],
"tasks": {
"_build": {
"env": ["GRAPH_WEB_BASE"],
"inputs": ["$TURBO_EXTENDS$", "index.html", "build/**", "vite.config.ts"]
},
"_test:e2e": {
Expand Down
Loading