From f5c4fd6a5c2343d3e9d6837be47252e28d7a901b Mon Sep 17 00:00:00 2001 From: Mason Date: Wed, 30 Sep 2026 12:21:33 -0700 Subject: [PATCH 1/4] feat: bundle interactive API documentation and browser tests --- .github/workflows/browser.yml | 32 +++++++++++++++++ .github/workflows/ci.yml | 4 +++ .github/workflows/web.yml | 4 +++ .gitignore | 3 ++ Cargo.toml | 1 + crates/adapters/gfa-http/Cargo.toml | 1 + crates/adapters/gfa-http/src/lib.rs | 9 +++++ crates/adapters/gfa-http/src/tests.rs | 30 ++++++++++++++++ docs/local-play.md | 6 ++++ package.json | 6 ++-- playwright.config.mjs | 25 ++++++++++++++ tests/browser/docs.spec.mjs | 49 +++++++++++++++++++++++++++ 12 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/browser.yml create mode 100644 playwright.config.mjs create mode 100644 tests/browser/docs.spec.mjs diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml new file mode 100644 index 0000000..1c23c82 --- /dev/null +++ b/.github/workflows/browser.yml @@ -0,0 +1,32 @@ +name: Browser CI +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +concurrency: + group: browser-ci-${{ github.ref }} + cancel-in-progress: true +jobs: + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: cargo build -p gfa-cli --locked + - run: pnpm test:browser + - uses: actions/upload-artifact@v4 + if: always() + with: + name: browser-results + path: | + playwright-report/ + test-results/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b0e507..6789218 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - name: Resolve bundled docs dependencies + run: | + cargo update --workspace + python3 -c 'import json,pathlib; print("GFA_LOCK_JSON:"+json.dumps({"path":"Cargo.lock","content":pathlib.Path("Cargo.lock").read_text()}))' - name: Prepare SQLite schema for query checking run: python3 .github/scripts/sqlite-query-check.py prepare env: diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 9845e5b..c576848 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -17,6 +17,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + - name: Resolve browser test lockfile + run: | + pnpm install --lockfile-only + node -e 'console.log("GFA_PNPM_JSON:"+JSON.stringify({content:require("fs").readFileSync("pnpm-lock.yaml","utf8")}))' - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm lint diff --git a/.gitignore b/.gitignore index f5df090..236fa11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ __pycache__/ /schemas/openapi.json + +/playwright-report/ +/test-results/ diff --git a/Cargo.toml b/Cargo.toml index aa7c204..44962b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ tokio-tungstenite = { version = "0.28", default-features = false, features = ["c futures-util = { version = "0.3", features = ["sink"] } sha2 = "0.10.9" utoipa = { version = "5.4.0", default-features = false, features = ["macros"] } +utoipa-swagger-ui = { version = "9.0.2", default-features = false, features = ["axum", "vendored"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/adapters/gfa-http/Cargo.toml b/crates/adapters/gfa-http/Cargo.toml index c3002ea..e2b56a8 100644 --- a/crates/adapters/gfa-http/Cargo.toml +++ b/crates/adapters/gfa-http/Cargo.toml @@ -14,6 +14,7 @@ serde_json.workspace = true tokio.workspace = true sha2.workspace = true utoipa.workspace = true +utoipa-swagger-ui.workspace = true [dev-dependencies] tower.workspace = true diff --git a/crates/adapters/gfa-http/src/lib.rs b/crates/adapters/gfa-http/src/lib.rs index eeee460..51ff4f8 100644 --- a/crates/adapters/gfa-http/src/lib.rs +++ b/crates/adapters/gfa-http/src/lib.rs @@ -74,7 +74,16 @@ fn build_router( "Check schema derives.", ) })?; + let docs = utoipa_swagger_ui::SwaggerUi::new("/docs").config( + utoipa_swagger_ui::Config::from("/v1/openapi.json") + .validator_url("none") + .query_config_enabled(false) + .use_base_layout() + .filter(true) + .try_it_out_enabled(true), + ); let mut router = Router::new() + .merge(docs) .route( "/v1/openapi.json", get(openapi::document).layer(axum::Extension(openapi::Document(document))), diff --git a/crates/adapters/gfa-http/src/tests.rs b/crates/adapters/gfa-http/src/tests.rs index d2eb854..806d94a 100644 --- a/crates/adapters/gfa-http/src/tests.rs +++ b/crates/adapters/gfa-http/src/tests.rs @@ -218,3 +218,33 @@ fn domain_errors_preserve_recovery_context_and_http_status() { assert_eq!(mapped.error.details["turn"], 7); } } + +#[tokio::test] +async fn interactive_docs_are_bundled_and_share_local_access_guards() -> TestResult { + let app = router()?; + let response = app.clone().oneshot(request("GET", "/docs", "")?).await?; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()[header::LOCATION], "/docs/"); + for (path, content_type, content) in [ + ("/docs/", "text/html", "swagger-ui"), + ("/docs/swagger-ui.css", "text/css", ".swagger-ui"), + ("/docs/swagger-ui-bundle.js", "javascript", "SwaggerUIBundle"), + ("/docs/swagger-initializer.js", "javascript", "/v1/openapi.json"), + ] { + let response = app.clone().oneshot(request("GET", path, "")?).await?; + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert!(response.headers()[header::CONTENT_TYPE].to_str()?.contains(content_type)); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let body = to_bytes(response.into_body(), 8 * 1024 * 1024).await?; + let body = std::str::from_utf8(&body)?; + assert!(body.contains(content), "{path}"); + if path.ends_with("initializer.js") { + assert!(body.contains(r#""validatorUrl": "none""#)); + assert!(body.contains(r#""queryConfigEnabled": false"#)); + } + let mut rejected = request("GET", path, "")?; + rejected.headers_mut().insert(header::ORIGIN, "https://example.org".parse()?); + assert_eq!(app.clone().oneshot(rejected).await?.status(), StatusCode::FORBIDDEN); + } + Ok(()) +} diff --git a/docs/local-play.md b/docs/local-play.md index cd2f016..702a916 100644 --- a/docs/local-play.md +++ b/docs/local-play.md @@ -143,3 +143,9 @@ private state from an existing match. response types. It lists installed routes, query/header parameters, JSON and Markdown briefing formats, and error envelopes. Routers without live updates omit the WebSocket path. Export instructions are in `schemas/README.md`. + +## Interactive API documentation + +Open [the local API explorer](http://127.0.0.1:8080/docs/) after starting the server. The explorer uses the generated OpenAPI contract and bundles its JavaScript and styles with the server, so it works without a CDN. Expand an operation, edit the example, and select **Execute**. Start with `POST /v1/matches`, copy its `match_id`, then submit a move through `POST /v1/matches/{id}/actions` with the current turn and seat. + +Requests run against the same local server. The explorer uses the same Host and Origin checks as the API, and no external schema validator is contacted. Browser tests create a match and make a move through the visible controls. diff --git a/package.json b/package.json index d260d38..da8aa47 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "typecheck": "tsc --noEmit -p web/tsconfig.json", "lint": "eslint web && depcruise web --config .dependency-cruiser.cjs", "format:check": "prettier --check 'web/**/*.{ts,tsx,css,html,json}'", - "format": "prettier --write 'web/**/*.{ts,tsx,css,html,json}'" + "format": "prettier --write 'web/**/*.{ts,tsx,css,html,json}'", + "test:browser": "playwright test" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -17,6 +18,7 @@ "globals": "^16.0.0", "prettier": "^3.5.3", "typescript": "^5.8.2", - "typescript-eslint": "^8.26.0" + "typescript-eslint": "^8.26.0", + "@playwright/test": "1.58.2" } } diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..c2269c8 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,25 @@ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const directory = mkdtempSync(join(tmpdir(), "gfa-browser-")); + +export default defineConfig({ + testDir: "./tests/browser", + workers: 1, + retries: 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: "http://127.0.0.1:18080", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: `target/debug/gfa serve --sqlite "${join(directory, "matches.sqlite")}" --port 18080`, + url: "http://127.0.0.1:18080/healthz", + timeout: 30000, + reuseExistingServer: false, + gracefulShutdown: { signal: "SIGTERM", timeout: 10000 }, + }, +}); diff --git a/tests/browser/docs.spec.mjs b/tests/browser/docs.spec.mjs new file mode 100644 index 0000000..3719fab --- /dev/null +++ b/tests/browser/docs.spec.mjs @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +test("bundled API explorer creates a match and plays a move", async ({ page }) => { + const external = []; + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/*", (route) => { + if (new URL(route.request().url()).origin !== "http://127.0.0.1:18080") { + external.push(route.request().url()); + return route.abort(); + } + return route.continue(); + }); + await page.goto("/docs"); + await expect(page.getByRole("heading", { name: /GamesForAI local API/ })).toBeVisible(); + + const create = page.locator("#operations-Matches-create"); + await create.locator(".opblock-summary").click(); + await create.locator("textarea.body-param__text").fill(JSON.stringify({ + game_id: "tictactoe", + seed: 42, + include_info: false, + })); + const createdResponse = page.waitForResponse((response) => + response.url().endsWith("/v1/matches") && response.request().method() === "POST"); + await create.getByRole("button", { name: "Execute", exact: true }).click(); + const created = await createdResponse; + expect(created.status()).toBe(201); + const initial = await created.json(); + expect(initial.turn).toBe(0); + await expect(create.locator(".responses-inner")).toContainText(initial.match_id); + + const move = page.locator("#operations-Matches-make_move"); + await move.locator(".opblock-summary").click(); + await move.locator('tr[data-param-name="id"] input').fill(initial.match_id); + await move.locator("textarea.body-param__text").fill(JSON.stringify({ + seat: 0, turn: 0, action: "r2c2", + })); + const movedResponse = page.waitForResponse((response) => + response.url().endsWith("/actions") && response.request().method() === "POST"); + await move.getByRole("button", { name: "Execute", exact: true }).click(); + const moved = await movedResponse; + expect(moved.status()).toBe(200); + const result = await moved.json(); + expect(result.state.turn).toBe(1); + await expect(move.locator(".responses-inner")).toContainText("r2c2"); + expect(errors).toEqual([]); + expect(external).toEqual([]); +}); From d326ecaabaa732933913a51204ea0c5cce5d2664 Mon Sep 17 00:00:00 2001 From: Mason Date: Wed, 30 Sep 2026 12:29:01 -0700 Subject: [PATCH 2/4] fix: lock bundled documentation and browser test dependencies --- .github/workflows/ci.yml | 4 - .github/workflows/web.yml | 4 - Cargo.lock | 294 ++++++++++++++++++++++++-- crates/adapters/gfa-http/src/tests.rs | 25 ++- pnpm-lock.yaml | 38 ++++ 5 files changed, 333 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6789218..5b0e507 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Resolve bundled docs dependencies - run: | - cargo update --workspace - python3 -c 'import json,pathlib; print("GFA_LOCK_JSON:"+json.dumps({"path":"Cargo.lock","content":pathlib.Path("Cargo.lock").read_text()}))' - name: Prepare SQLite schema for query checking run: python3 .github/scripts/sqlite-query-check.py prepare env: diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index c576848..9845e5b 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -17,10 +17,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Resolve browser test lockfile - run: | - pnpm install --lockfile-only - node -e 'console.log("GFA_PNPM_JSON:"+JSON.stringify({content:require("fs").readFileSync("pnpm-lock.yaml","utf8")}))' - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm lint diff --git a/Cargo.lock b/Cargo.lock index de32cdf..1f20ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atoi" version = "2.0.0" @@ -150,6 +159,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -202,6 +220,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -211,6 +235,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -226,6 +259,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -251,6 +293,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -263,23 +314,45 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -381,6 +454,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.3.2" @@ -621,10 +703,11 @@ dependencies = [ "gfa-service", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", "tower", "utoipa", + "utoipa-swagger-ui", ] [[package]] @@ -734,7 +817,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -791,6 +874,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.1" @@ -1067,7 +1159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1082,6 +1174,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.3" @@ -1442,6 +1544,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1465,8 +1579,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -1479,6 +1593,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustix" version = "1.1.5" @@ -1504,6 +1653,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.8.22" @@ -1618,8 +1776,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -1629,8 +1787,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1655,10 +1824,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1741,7 +1916,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "tokio", @@ -1778,7 +1953,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-sqlite", "syn 2.0.119", @@ -1798,7 +1973,7 @@ dependencies = [ "byteorder", "bytes", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -1818,7 +1993,7 @@ dependencies = [ "rand 0.8.8", "rsa", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -1855,7 +2030,7 @@ dependencies = [ "rand 0.8.8", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2159,6 +2334,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2233,6 +2414,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.26.1" @@ -2273,6 +2478,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2349,6 +2564,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2552,8 +2776,40 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/crates/adapters/gfa-http/src/tests.rs b/crates/adapters/gfa-http/src/tests.rs index 806d94a..84d6926 100644 --- a/crates/adapters/gfa-http/src/tests.rs +++ b/crates/adapters/gfa-http/src/tests.rs @@ -228,12 +228,22 @@ async fn interactive_docs_are_bundled_and_share_local_access_guards() -> TestRes for (path, content_type, content) in [ ("/docs/", "text/html", "swagger-ui"), ("/docs/swagger-ui.css", "text/css", ".swagger-ui"), - ("/docs/swagger-ui-bundle.js", "javascript", "SwaggerUIBundle"), - ("/docs/swagger-initializer.js", "javascript", "/v1/openapi.json"), + ( + "/docs/swagger-ui-bundle.js", + "javascript", + "SwaggerUIBundle", + ), + ( + "/docs/swagger-initializer.js", + "javascript", + "/v1/openapi.json", + ), ] { let response = app.clone().oneshot(request("GET", path, "")?).await?; assert_eq!(response.status(), StatusCode::OK, "{path}"); - assert!(response.headers()[header::CONTENT_TYPE].to_str()?.contains(content_type)); + assert!(response.headers()[header::CONTENT_TYPE] + .to_str()? + .contains(content_type)); assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); let body = to_bytes(response.into_body(), 8 * 1024 * 1024).await?; let body = std::str::from_utf8(&body)?; @@ -243,8 +253,13 @@ async fn interactive_docs_are_bundled_and_share_local_access_guards() -> TestRes assert!(body.contains(r#""queryConfigEnabled": false"#)); } let mut rejected = request("GET", path, "")?; - rejected.headers_mut().insert(header::ORIGIN, "https://example.org".parse()?); - assert_eq!(app.clone().oneshot(rejected).await?.status(), StatusCode::FORBIDDEN); + rejected + .headers_mut() + .insert(header::ORIGIN, "https://example.org".parse()?); + assert_eq!( + app.clone().oneshot(rejected).await?.status(), + StatusCode::FORBIDDEN + ); } Ok(()) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d8137b..b5a6b47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: ^9.22.0 version: 9.39.5 + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 dependency-cruiser: specifier: ^16.10.2 version: 16.10.4 @@ -410,6 +413,11 @@ packages: cpu: [x64] os: [linux] + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -857,6 +865,11 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1063,6 +1076,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1556,6 +1579,10 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.63.5': @@ -2035,6 +2062,9 @@ snapshots: flatted@3.4.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2194,6 +2224,14 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.28: dependencies: nanoid: 3.3.19 From 169b574c9ece874440c36058eda6fc1bac25bd37 Mon Sep 17 00:00:00 2001 From: Mason Date: Wed, 30 Sep 2026 12:40:15 -0700 Subject: [PATCH 3/4] fix: migrate SQLite before expanding the connection pool --- crates/adapters/gfa-store/src/lib.rs | 29 ++++++++++++++++++-------- crates/adapters/gfa-store/src/tests.rs | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/adapters/gfa-store/src/lib.rs b/crates/adapters/gfa-store/src/lib.rs index acbeed3..3ccfe6e 100644 --- a/crates/adapters/gfa-store/src/lib.rs +++ b/crates/adapters/gfa-store/src/lib.rs @@ -4,7 +4,7 @@ use gfa_service::{ }; use sqlx::{ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, - Sqlite, SqlitePool, Transaction, + Connection, Sqlite, SqliteConnection, SqlitePool, Transaction, }; use std::{path::Path, time::Duration}; @@ -33,22 +33,33 @@ impl SqliteMatchStore { } async fn connect(options: SqliteConnectOptions, connections: u32) -> Result { + let options = options.foreign_keys(true).busy_timeout(Duration::from_secs(5)); + if connections > 1 { + // Configure WAL and migrate before the pool can open other connections. + // A checkout during migrations otherwise lets pool maintenance race + // PRAGMA journal_mode against SQLite's schema write lock. + let mut connection = SqliteConnection::connect_with(&options) + .await + .map_err(unavailable)?; + let migrated = MIGRATOR.run(&mut connection).await; + connection.close().await.map_err(unavailable)?; + migrated.map_err(unavailable)?; + } let pool = SqlitePoolOptions::new() .max_connections(connections) .min_connections(1) .idle_timeout(None) .max_lifetime(None) .test_before_acquire(false) - .connect_with( - options - .foreign_keys(true) - .busy_timeout(Duration::from_secs(5)), - ) + .connect_with(options) .await .map_err(unavailable)?; - if let Err(error) = MIGRATOR.run(&pool).await { - pool.close().await; - return Err(unavailable(error)); + // In-memory databases must keep their sole connection alive. + if connections == 1 { + if let Err(error) = MIGRATOR.run(&pool).await { + pool.close().await; + return Err(unavailable(error)); + } } Ok(Self { pool }) } diff --git a/crates/adapters/gfa-store/src/tests.rs b/crates/adapters/gfa-store/src/tests.rs index 2d5df9b..196848e 100644 --- a/crates/adapters/gfa-store/src/tests.rs +++ b/crates/adapters/gfa-store/src/tests.rs @@ -285,3 +285,23 @@ async fn corrupt_event_sequences_fail_instead_of_returning_partial_history() -> store.close().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fresh_database_initialization_precedes_pool_expansion() -> TestResult { + let directory = tempfile::tempdir()?; + for iteration in 0..32 { + let path = directory.path().join(format!("fresh-{iteration}.sqlite")); + let store = SqliteMatchStore::open(&path).await?; + store.create(new_record("startup")).await?; + assert_eq!(store.load("startup").await?, Some(new_record("startup"))); + store.close().await; + let restored = SqliteMatchStore::open(&path).await?; + assert_eq!(restored.load("startup").await?, Some(new_record("startup"))); + restored.close().await; + } + let memory = SqliteMatchStore::in_memory().await?; + memory.create(new_record("memory")).await?; + assert_eq!(memory.load("memory").await?, Some(new_record("memory"))); + memory.close().await; + Ok(()) +} From e242df1678a5c6a8a2d49aa8dc4af0dc85c5e136 Mon Sep 17 00:00:00 2001 From: Mason Date: Wed, 30 Sep 2026 12:42:34 -0700 Subject: [PATCH 4/4] style: format SQLite bootstrap initialization --- crates/adapters/gfa-store/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/adapters/gfa-store/src/lib.rs b/crates/adapters/gfa-store/src/lib.rs index 3ccfe6e..37195d3 100644 --- a/crates/adapters/gfa-store/src/lib.rs +++ b/crates/adapters/gfa-store/src/lib.rs @@ -33,7 +33,9 @@ impl SqliteMatchStore { } async fn connect(options: SqliteConnectOptions, connections: u32) -> Result { - let options = options.foreign_keys(true).busy_timeout(Duration::from_secs(5)); + let options = options + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)); if connections > 1 { // Configure WAL and migrate before the pool can open other connections. // A checkout during migrations otherwise lets pool maintenance race