From f59a964ffbe184a17ce145f4aabfd6bdd711f2b4 Mon Sep 17 00:00:00 2001 From: iperev Date: Sun, 6 Sep 2026 08:31:11 +0200 Subject: [PATCH] feat: add bounded browser lookup and responsive navigation --- docs/proofkit-contract-map.md | 1 + .../proofkit-spec-proof-core/overview.md | 7 + .../requirements.v1.json | 26 ++ internal/app/cli_contract_test.go | 2 +- internal/app/command_contract_generated.go | 4 +- internal/command/requirementbrowser/assets.go | 12 + .../assets/workspace-icons.js | 91 +++++ .../assets/workspace-navigation.js | 235 +++++++++++ .../assets/workspace-panels.js | 98 +++++ .../assets/workspace-requests.js | 35 ++ .../requirementbrowser/assets/workspace.css | 366 +++++++----------- .../requirementbrowser/assets/workspace.js | 215 +++++++--- .../requirementbrowser/http_handler.go | 46 ++- .../requirementbrowser/http_handler_test.go | 28 +- .../command/requirementbrowser/workspace.go | 97 ++--- .../workspace_http_capacity_test.go | 136 +++++++ .../requirementbrowser/workspace_lookup.go | 236 +++++++++++ .../workspace_lookup_admission_test.go | 77 ++++ .../workspace_lookup_fixture_test.go | 75 ++++ .../workspace_lookup_test.go | 129 ++++++ .../workspace_navigation.go | 78 ++++ .../requirementbrowser/workspace_page.go | 69 ++++ .../requirementbrowser/workspace_page_test.go | 76 ++++ .../requirementbrowser/workspace_test.go | 16 - .../stackpreset/preset_ids_generated.go | 2 +- .../testsupport/browserfixture/fixture.go | 16 +- internal/testsupport/browserfixture/lookup.go | 119 ++++++ .../testsupport/browserfixture/lookup_test.go | 35 ++ internal/tools/browsertestserver/main.go | 13 +- internal/tools/releasechange/record_test.go | 25 +- package-lock.json | 4 +- package.json | 4 +- proofkit/cli-contract.v2.json | 4 +- proofkit/requirement-bindings.json | 88 +++++ proofkit/witness-plan.json | 1 + release/change-record.v2.json | 35 +- scripts/browser-proof-inputs.test.mjs | 3 +- scripts/browser-runtime-proof-inputs.v1.json | 1 + scripts/browser-workspace-requests.test.mjs | 91 +++++ tests/browser/lookup.spec.mjs | 225 +++++++++++ tests/browser/workspace-errors.spec.mjs | 140 +++++++ tests/browser/workspace-locking.spec.mjs | 207 ++++++++++ .../browser/workspace-navigation-harness.mjs | 76 ++++ tests/browser/workspace-paging.spec.mjs | 56 +++ tests/browser/workspace-panels.spec.mjs | 137 +++++++ .../workspace-request-lifecycle.spec.mjs | 59 +++ tests/browser/workspace-test-harness.mjs | 44 +++ tests/browser/workspace.spec.mjs | 164 +++----- 48 files changed, 3155 insertions(+), 549 deletions(-) create mode 100644 internal/command/requirementbrowser/assets/workspace-icons.js create mode 100644 internal/command/requirementbrowser/assets/workspace-navigation.js create mode 100644 internal/command/requirementbrowser/assets/workspace-panels.js create mode 100644 internal/command/requirementbrowser/assets/workspace-requests.js create mode 100644 internal/command/requirementbrowser/workspace_http_capacity_test.go create mode 100644 internal/command/requirementbrowser/workspace_lookup.go create mode 100644 internal/command/requirementbrowser/workspace_lookup_admission_test.go create mode 100644 internal/command/requirementbrowser/workspace_lookup_fixture_test.go create mode 100644 internal/command/requirementbrowser/workspace_lookup_test.go create mode 100644 internal/command/requirementbrowser/workspace_navigation.go create mode 100644 internal/command/requirementbrowser/workspace_page.go create mode 100644 internal/command/requirementbrowser/workspace_page_test.go create mode 100644 internal/testsupport/browserfixture/lookup.go create mode 100644 internal/testsupport/browserfixture/lookup_test.go create mode 100644 scripts/browser-workspace-requests.test.mjs create mode 100644 tests/browser/lookup.spec.mjs create mode 100644 tests/browser/workspace-errors.spec.mjs create mode 100644 tests/browser/workspace-locking.spec.mjs create mode 100644 tests/browser/workspace-navigation-harness.mjs create mode 100644 tests/browser/workspace-paging.spec.mjs create mode 100644 tests/browser/workspace-panels.spec.mjs create mode 100644 tests/browser/workspace-request-lifecycle.spec.mjs create mode 100644 tests/browser/workspace-test-harness.mjs diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 30a7a8c..259bddf 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -154,6 +154,7 @@ Semantic context routes are `requirement-context-compose`, | Does a TypeScript package public API match a caller-owned manifest? | `agent-route` with `goal: "verify_typescript_public_api"` and explicit `typescript_public_api_manifest` plus `typescript_public_api_repo_root`, then `typescript-public-api-surfaces --repo-root ` | The manifest must name each referenced `package.json`, sorted-unique export conditions, and a non-JSX `.ts`, `.mts`, or `.cts` `sourcePath` whose canonical target has the same admitted extension class. The bounded scanner accepts only the fail-closed export grammar in `proofkit/cli-contract.v2.json`; it does not parse unrestricted TypeScript or TSX, infer conventional layouts, or prove compiler output provenance, checkout freshness, package-manager truth, or merge readiness. | | Receipts are available for planned checks. | `selective-gate-evidence --agent-envelope`; then materialize a caller-owned `obligation_decision_input` from the evidence output plus command routes, currentness, and trust facts; then run `selective-gate-obligation-decision-input`; then materialize the resulting `obligation_decision` input and run `obligation-decision --agent-envelope` | Escalate on missing, stale, invalid, untrusted, blocked, unavailable, or unknown-scope evidence. | | Human inspection, semantic comparison, or traceability navigation is needed. | `requirement-source-view`, `requirement-proof-view`, `requirement-coverage-view`, `requirement-spec-tree-view`, `requirement-semantic-diff`, `requirement-traceability-graph`, or `requirement-browser-server` | Semantic diff compares admitted owner fields rather than lines. Traceability keeps specification, proof, code, and native execution evidence planes separate. Browser and rendered outputs remain presentation only unless the consumer admits a tracked artifact freshness gate. | +| Find a requirement in a large admitted workspace. | `requirement-browser-server --view workspace --serve --input `; use Browse to select a descendant scope, owner, lifecycle, or literal search, then inspect the bounded result page. | Search covers the whole admitted cohort before paging, not just visible rows. Selection retains original source anchors; handoff adds context closure through its owner. Browse and Inspector become mutually exclusive native panels on smaller viewports. Retry preserves the failed operation, while stale snapshots require explicit reload. The browser does not execute agents or turn a lookup count into proof coverage. | | Temporary external document lifecycle facts, generated views, or rendered views need authority classification. | `document-lifecycle-boundary` | Treat lifecycle records as caller-owned metadata. Temporary design docs and implementation plans are not retained repository authority unless rewritten into deterministic specs, proof bindings, tests, package-public docs, or backlog rows. | | A JavaScript/TypeScript consumer needs less wrapper code. | `json-report-cli-adapter-source --language typescript --format json` | Generated adapter source is caller-owned after materialization. The consumer still owns package pin, binary path, repo paths, local policy, and freshness proof. It is a CLI runner adapter, not a separate SDK authority. | | A Python consumer needs Proofkit from Python tooling. | Install the Python package when available and invoke the same CLI/JSON contract. | The Python package is a runner wrapper over the Go CLI, not a Python SDK or alternate schema owner. | diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index cf425b1..0a30e36 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -207,6 +207,13 @@ execution receipts, and merge policy. change-plan route replacement, and omitted-route policy to one breaking release record without reinterpreting the frozen prior edge. +- `REQ-PROOFKIT-SPEC-036`: full-cohort requirement lookup intersects literal + search, owner, lifecycle, and typed descendant scope before byte-bounded + paging; navigation and handoff preserve their distinct identities and closure. +- `REQ-PROOFKIT-SPEC-037`: generation-owned requests, exact explicit Retry, + distinct error actions, and responsive native panels preserve keyboard + focus, source selection, and drafts without promoting presentation authority. + ## Non-Claims - This spec does not claim consumer repository adoption. diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 7ce8bcc..57b1242 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -711,6 +711,32 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Workspace lookup indexes immutable admitted requirement sources once, preserves each original source digest and snapshot JSON pointer, and intersects search, owner, lifecycle, and selected-node descendant scope before stable requirement-ID paging. Scope uses only requirement-role source-ID references, never overview or path-digest aliases or inferred ancestor membership. Search applies Unicode trimming and simple lowercase independently to ID, owner, and invariant, without normalization or cross-field concatenation, after 1024-byte and 256-code-point admission bounds. Available A, matching M, and selected S counts yield filtered A-M, page-omitted M-S, and total-omitted A-S counts. Lookup does not expand lifecycle closure; handoff delegates closure to the existing context owner. Navigation projects only the root or one admitted parent's ordered child window; both lookup routes cap compact response bytes at 16 MiB without truncating invariant text and reject an unfit first row before output. Browser navigation retains at most 256 rows with explicit recovery of discarded sibling pages, while requirement paging returns to actual visited offsets rather than assuming fixed response length.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-036"], + "nonClaims": ["Lookup fragments do not establish source completeness outside the admitted snapshot, lifecycle-closed context, proof coverage, native execution, provider freshness, merge approval, or production readiness. Private browser HTTP routes are not a separately supported public SDK."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The workspace has one content-generation owner and independent identity-bound navigation requests. Superseded, aborted, or collapsed-branch replies cannot replace current content, selection, focus, or request authority. Retry requires explicit activation and repeats the immutable failed method, route, snapshot, and complete query with a new request ID; unsent form edits do not alter it. Sanitized errors distinguish correction (400), denied and locked without Retry (403), stale and locked with explicit reload (409), explicit Retry for transport/429/5xx, and optional versus required unavailability (404). Responsive panels use one native modal at a time at or below 64rem, restore visible opener focus on close, and release modality on desktop resize while preserving the question draft. Inspector entry commits source selection before moving focus; content transitions clear targets and displayed packets. Navigation render commits preserve a focused node/action identity and derive protected-control state from current authority. Native page focus stays inside the open modal without trapping browser chrome. Invariants precede lazily expanded owner/non-claim details and remain source-bound through Unicode selection and handoff.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-037"], + "nonClaims": ["Browser runtime witnesses cover the admitted Playwright Chromium, Firefox, and WebKit scenarios, not all browser preferences, assistive technologies, operating-system themes, branded Safari behavior, complete WCAG conformance, annotation persistence, agent execution, or provider delivery."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 234b820..337f910 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -24,7 +24,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "527ffbc7e261d4ac0f91cc81db0a390ec5aa2c4f18593671bbc1f92f3ed83c70" + cliContractPublicABISHA256 = "679a9152618bff6c848bacea0aaf2ae09bef24f7d6176add1733248a287225ae" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index e18f83b..b376a79 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "bcbfee8a2bc84bf17a433e8cc05e34c9df4460d68b323177e579b0a8b8ff34fe" +const commandContractSourceSHA256 = "187b115fbdb47c8e88154e118a414a1f9e779c7663eec8a4a09c843a71bd61e9" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -67,7 +67,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:4a6fc5b5ef55090854e70927494d220afdee0ae234de4f61a720a6018865f02f", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, "requirement-authoring-plan": {InputContractSHA256: "sha256:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-authoring-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-authoring-plan"}}, "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-bindings.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-bindings"}}, - "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input schemaVersion=2 (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts", "root-shape-only definition proofkit.requirement-browser-server.input.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, + "requirement-browser-server": {InputContractSHA256: "sha256:0bd65165c4c8c56a66081d1db4caabf5814957706c23ae4868bc68eadbc460d5", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input schemaVersion=2 (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts", "root-shape-only definition proofkit.requirement-browser-server.input.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:08ea562adc8fdbfb8bc30851a91b7f64d00d35e193b13475e6f98d83b8aaa528", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, "requirement-context-compose": {InputContractSHA256: "sha256:3b06ebca2a07d01b34005d915918b8a7743ef8175901ee8dd0a37ecfb6ee80fe", InputSchemaSummary: []string{"schemaVersion=1", "catalogId", "specTree.path", "requirementSources[] (non-empty)", "requirementSources[].nodeId", "requirementSources[].path", "expectedSourceDigest (optional sha256 ref)", "proofBinding.path (optional)", "coverage.path (optional)", "exact catalog paths only; no discovery", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a2b8bf00d2308628e1835fe890dc6e9c4f402c531726f0bdaffad1d7100a7466", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-compose"}}, "requirement-context-slice": {InputContractSHA256: "sha256:883e864e44944270f7b85c013635835e5e1f1a49a7fd7ca3ebe860c7b56da601", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:971f78ec94bb29a24057343ccfb3d1192134d2ac88968aab5193e3d64ac87506", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-slice"}}, "requirement-coverage-input-compose": {InputContractSHA256: "sha256:1980d1fc5c3c3cfe08f557e3c55f7128de5bae0d9b25bd2a46d7c6e47db73faa", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3a8686e91f3a229b273531d9c03cb6ce86ec76b66f8c35c6ca7f361b6e5eeaff", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-input-compose"}}, diff --git a/internal/command/requirementbrowser/assets.go b/internal/command/requirementbrowser/assets.go index 1a19ea1..8e6cddf 100644 --- a/internal/command/requirementbrowser/assets.go +++ b/internal/command/requirementbrowser/assets.go @@ -8,5 +8,17 @@ var workspaceJavaScript []byte //go:embed assets/selection-authority.js var selectionAuthorityJavaScript []byte +//go:embed assets/workspace-icons.js +var workspaceIconsJavaScript []byte + +//go:embed assets/workspace-panels.js +var workspacePanelsJavaScript []byte + +//go:embed assets/workspace-requests.js +var workspaceRequestsJavaScript []byte + +//go:embed assets/workspace-navigation.js +var workspaceNavigationJavaScript []byte + //go:embed assets/workspace.css var workspaceCSS []byte diff --git a/internal/command/requirementbrowser/assets/workspace-icons.js b/internal/command/requirementbrowser/assets/workspace-icons.js new file mode 100644 index 0000000..db7e100 --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace-icons.js @@ -0,0 +1,91 @@ +// @ts-check +// Static subset of lucide 1.41.0, package/dist/esm/icons/*.mjs. +// https://www.npmjs.com/package/lucide/v/1.41.0 +// sha512-fIXP7JzC1vehdqOrMhwpbcXzX6CvUMuoDf16HY1FmC4sDBeuwrH2A0c6QmLn3wnSvaMchpEyM+JWijyTvOs3kw== +/* +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/** @type {Record][]>} */ +const nodes = { + "panel-left": [["rect", {width: "18", height: "18", x: "3", y: "3", rx: "2"}], ["path", {d: "M9 3v18"}]], + "panel-right": [["rect", {width: "18", height: "18", x: "3", y: "3", rx: "2"}], ["path", {d: "M15 3v18"}]], + search: [["path", {d: "m21 21-4.34-4.34"}], ["circle", {cx: "11", cy: "11", r: "8"}]], + x: [["path", {d: "M18 6 6 18"}], ["path", {d: "m6 6 12 12"}]], + "chevron-right": [["path", {d: "m9 18 6-6-6-6"}]], + "chevron-down": [["path", {d: "m6 9 6 6 6-6"}]], + "arrow-left": [["path", {d: "m12 19-7-7 7-7"}], ["path", {d: "M19 12H5"}]], + "arrow-right": [["path", {d: "M5 12h14"}], ["path", {d: "m12 5 7 7-7 7"}]], + "refresh-cw": [["path", {d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}], ["path", {d: "M21 3v5h-5"}], ["path", {d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}], ["path", {d: "M8 16H3v5"}]], + "file-text": [["path", {d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}], ["path", {d: "M14 2v5a1 1 0 0 0 1 1h5"}], ["path", {d: "M10 9H8"}], ["path", {d: "M16 13H8"}], ["path", {d: "M16 17H8"}]], + "git-compare-arrows": [["circle", {cx: "5", cy: "6", r: "3"}], ["path", {d: "M12 6h5a2 2 0 0 1 2 2v7"}], ["path", {d: "m15 9-3-3 3-3"}], ["circle", {cx: "19", cy: "18", r: "3"}], ["path", {d: "M12 18H7a2 2 0 0 1-2-2V9"}], ["path", {d: "m9 15 3 3-3 3"}]], + network: [["rect", {x: "16", y: "16", width: "6", height: "6", rx: "1"}], ["rect", {x: "2", y: "16", width: "6", height: "6", rx: "1"}], ["rect", {x: "9", y: "2", width: "6", height: "6", rx: "1"}], ["path", {d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"}], ["path", {d: "M12 12V8"}]], + "message-square": [["path", {d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"}]], + info: [["circle", {cx: "12", cy: "12", r: "10"}], ["path", {d: "M12 16v-4"}], ["path", {d: "M12 8h.01"}]], + check: [["path", {d: "M20 6 9 17l-5-5"}]], +}; + +/** @param {string} name */ +export function icon(name) { + if (!Object.hasOwn(nodes, name)) throw new Error("Unknown workspace icon"); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + for (const [key, value] of Object.entries({width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false", class: "workspace-icon"})) svg.setAttribute(key, value); + for (const [tag, attributes] of nodes[name]) { + const child = document.createElementNS(svg.namespaceURI, tag); + for (const [key, value] of Object.entries(attributes)) child.setAttribute(key, value); + svg.append(child); + } + return svg; +} + +/** @param {ParentNode} root */ +export function decorateIcons(root) { + for (const element of root.querySelectorAll("[data-icon]")) { + if (element instanceof HTMLElement && element.dataset.icon) { + element.prepend(icon(element.dataset.icon)); + delete element.dataset.icon; + } + } +} diff --git a/internal/command/requirementbrowser/assets/workspace-navigation.js b/internal/command/requirementbrowser/assets/workspace-navigation.js new file mode 100644 index 0000000..8afba18 --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace-navigation.js @@ -0,0 +1,235 @@ +// @ts-check + +import {icon} from "./workspace-icons.js"; + +/** @typedef {{nodeId: string, label: string, nodeKind: string, childCount: number}} NavigationNode */ +/** @typedef {{parentNodeId?: string, offset: number, maxRecords: number}} NavigationQuery */ +/** @typedef {{query: NavigationQuery, history: number[], rows: NavigationNode[], available: number, reduced: boolean, ready: boolean, controller: AbortController, requestId: string, error?: unknown}} Branch */ +/** @typedef {{searchText?: string, nodeId?: string, ownerId?: string, lifecycleState?: string}} LookupFilters */ + +/** + * @param {{post: (path: string, body: any, signal?: AbortSignal) => Promise, nextRequestId: (prefix: string) => string, select: (filters: LookupFilters) => void, fail: (error: unknown, container: HTMLElement, retry: () => void) => void, locked: () => boolean}} actions + */ +export function initializeNavigation(actions) { + const form = document.querySelector("#workspace-search"); + const search = document.querySelector("#requirement-search"); + const owner = document.querySelector("#requirement-owner"); + const lifecycle = document.querySelector("#requirement-lifecycle"); + const tree = document.querySelector("#spec-navigation"); + const scope = document.querySelector("#selected-scope"); + if (!(form instanceof HTMLFormElement) || !(search instanceof HTMLInputElement) || !(owner instanceof HTMLSelectElement) || !(lifecycle instanceof HTMLSelectElement) || !(tree instanceof HTMLElement) || !(scope instanceof HTMLElement)) throw new Error("Missing navigation controls"); + const controls = {form, search, owner, lifecycle, tree, scope}; + /** @type {Branch[]} */ + let branches = []; + /** @type {NavigationNode[]} */ + let selectedPath = []; + let snapshotId = ""; + const maxVisibleRows = 256; + + function submit() { + if (actions.locked() || !snapshotId) return; + /** @type {LookupFilters} */ + const filters = {}; + if (controls.search.value) filters.searchText = controls.search.value; + if (controls.owner.value) filters.ownerId = controls.owner.value; + if (controls.lifecycle.value) filters.lifecycleState = controls.lifecycle.value; + const selected = selectedPath.at(-1); + if (selected) filters.nodeId = selected.nodeId; + actions.select(Object.freeze(filters)); + } + + controls.form.addEventListener("submit", event => { event.preventDefault(); submit(); }); + controls.owner.addEventListener("change", submit); + controls.lifecycle.addEventListener("change", submit); + document.querySelector("#reset-filters")?.addEventListener("click", () => { + controls.form.reset(); + selectedPath = []; + render(); + submit(); + }); + document.querySelector("#all-requirements")?.addEventListener("click", () => { + selectedPath = []; + render(); + submit(); + }); + + /** @param {number} depth */ + function discardFrom(depth) { + for (const branch of branches.slice(depth)) branch.controller.abort(); + branches = branches.slice(0, depth); + } + + /** @param {number} depth @param {NavigationNode} node */ + function pathTo(depth, node) { + const path = branches.slice(0, depth).map((branch, index) => branch.rows.find(row => row.nodeId === branches[index + 1]?.query.parentNodeId)); + if (path.some(row => !row)) throw new Error("Navigation path is incomplete"); + return [.../** @type {NavigationNode[]} */ (path), node]; + } + + function capacity() { + if (controls.tree.querySelector("[data-navigation-capacity]")) return; + const status = document.createElement("p"); + status.dataset.navigationCapacity = ""; + status.setAttribute("role", "status"); + status.textContent = "The navigation path reached its display limit. Collapse a branch to continue."; + controls.tree.append(status); + } + + /** @param {number} depth @param {string | undefined} parentNodeId @param {number} offset @param {number[]} history @param {NavigationQuery} [retryQuery] */ + async function load(depth, parentNodeId, offset, history, retryQuery) { + if (actions.locked()) return; + if (depth >= maxVisibleRows) { capacity(); return; } + const focusInTree = controls.tree.contains(document.activeElement); + discardFrom(depth); + // Old sibling pages can be recovered explicitly; keep the expanded path. + for (let index = 0; index < branches.length; index += 1) { + if (branches.reduce((count, branch) => count + branch.rows.length, 0) + 64 <= maxVisibleRows) break; + const branch = branches[index]; + const expandedId = branches[index + 1]?.query.parentNodeId ?? parentNodeId; + const expanded = branch.rows.find(row => row.nodeId === expandedId); + if (expanded && branch.rows.length > 1) { branch.rows = [expanded]; branch.reduced = true; } + } + const remaining = maxVisibleRows - branches.reduce((count, branch) => count + branch.rows.length, 0); + if (remaining < 1) { render(); capacity(); return; } + const query = retryQuery ?? Object.freeze({...parentNodeId ? {parentNodeId} : {}, offset, maxRecords: Math.min(64, remaining)}); + const branch = {query, history, rows: /** @type {NavigationNode[]} */ ([]), available: 0, reduced: false, ready: false, controller: new AbortController(), requestId: actions.nextRequestId("browser.navigation")}; + branches.push(branch); + render(); + if (focusInTree && parentNodeId) restoreNodeFocus(parentNodeId, "disclosure"); + try { + const response = await actions.post("/api/v1/navigation", {requestId: branch.requestId, snapshotId, query}, branch.controller.signal); + if (branch.controller.signal.aborted || branches[depth] !== branch) return; + if (response?.requestId !== branch.requestId || response?.snapshotId !== snapshotId) throw new Error("Navigation response identity mismatch"); + if ((response.projection.parent?.nodeId ?? undefined) !== query.parentNodeId) throw new Error("Navigation parent identity mismatch"); + branch.rows = response.projection.nodes; + branch.available = response.projection.availableNodeCount; + branch.ready = true; + render(); + } catch (error) { + if (branch.controller.signal.aborted || branches[depth] !== branch) return; + /** @type {Branch} */ (branch).error = error; + render(); + } + } + + /** @param {string} label @param {string} symbol @param {() => void} action */ + function button(label, symbol, action) { + const control = document.createElement("button"); + control.type = "button"; + control.dataset.protectedRequest = ""; + control.disabled = actions.locked(); + control.title = label; + control.setAttribute("aria-label", label); + control.append(icon(symbol)); + control.addEventListener("click", action); + return control; + } + + /** @param {number} depth @returns {HTMLElement} */ + function branchView(depth) { + const branch = branches[depth]; + const section = document.createElement("div"); + section.id = `navigation-branch-${depth}`; + section.dataset.navigationBranch = String(depth); + if (Object.hasOwn(branch, "error")) { + actions.fail(branch.error, section, () => { + if (branches[depth] === branch && !branch.controller.signal.aborted) void load(depth, branch.query.parentNodeId, branch.query.offset, branch.history, branch.query); + }); + return section; + } + const list = document.createElement("ul"); + list.className = "navigation-nodes"; + section.append(list); + for (const node of branch.rows) { + const item = document.createElement("li"); + item.dataset.navigationNode = node.nodeId; + const row = document.createElement("div"); + row.className = "navigation-row"; + const expanded = branches[depth + 1]?.query.parentNodeId === node.nodeId; + if (node.childCount > 0) { + const disclosure = button(`${expanded ? "Collapse" : "Expand"} ${node.label}`, expanded ? "chevron-down" : "chevron-right", () => { + if (expanded) { discardFrom(depth + 1); render(); } + else void load(depth + 1, node.nodeId, 0, []); + }); + disclosure.className = "icon-button"; + disclosure.dataset.navigationAction = "disclosure"; + disclosure.setAttribute("aria-expanded", String(expanded)); + if (expanded) disclosure.setAttribute("aria-controls", `navigation-branch-${depth + 1}`); + row.append(disclosure); + } + const choose = button(node.label, "file-text", () => { selectedPath = pathTo(depth, node); render(); submit(); }); + choose.className = "navigation-label"; + choose.dataset.navigationAction = "select"; + choose.append(document.createTextNode(node.label)); + if (selectedPath.at(-1)?.nodeId === node.nodeId) choose.setAttribute("aria-current", "true"); + row.append(choose); + item.append(row); + if (expanded) item.append(branchView(depth + 1)); + list.append(item); + } + if (branch.rows.length === 0 && branch.available === 0) { + const status = document.createElement("p"); + status.setAttribute("role", "status"); + status.textContent = branch.ready ? "No child specifications." : "Loading navigation..."; + section.append(status); + } + const paging = document.createElement("div"); + paging.className = "navigation-paging"; + if (branch.reduced) { + const siblings = button("Show sibling page", "arrow-left", () => void load(depth, branch.query.parentNodeId, branch.query.offset, branch.history)); + siblings.append(document.createTextNode("Show siblings")); + paging.append(siblings); + } else { + if (branch.history.length > 0) paging.append(button("Previous navigation page", "arrow-left", () => void load(depth, branch.query.parentNodeId, branch.history.at(-1) ?? 0, branch.history.slice(0, -1)))); + if (branch.query.offset + branch.rows.length < branch.available) paging.append(button("Next navigation page", "arrow-right", () => void load(depth, branch.query.parentNodeId, branch.query.offset + branch.rows.length, [...branch.history, branch.query.offset]))); + } + section.append(paging); + return section; + } + + /** @param {string} nodeId @param {string} action */ + function restoreNodeFocus(nodeId, action) { + if (action !== "disclosure" && action !== "select") return; + const control = controls.tree.querySelector(`[data-navigation-node="${CSS.escape(nodeId)}"] > .navigation-row > [data-navigation-action="${action}"]`); + if (control instanceof HTMLButtonElement && !control.disabled) control.focus({preventScroll: true}); + } + + function render() { + const active = document.activeElement; + const focusedNode = active instanceof HTMLElement && controls.tree.contains(active) ? active.closest("[data-navigation-node]") : null; + const nodeId = focusedNode instanceof HTMLElement ? focusedNode.dataset.navigationNode : undefined; + const action = active instanceof HTMLElement ? active.dataset.navigationAction : undefined; + controls.scope.replaceChildren(); + if (selectedPath.length > 0) { + const label = document.createElement("p"); + label.textContent = selectedPath.map(node => node.label).join(" / "); + controls.scope.append(label); + } + controls.tree.replaceChildren(); + if (branches.length > 0) { + const view = branchView(0); + for (const control of view.querySelectorAll("[data-protected-request]")) { + if (control instanceof HTMLButtonElement) control.disabled = actions.locked(); + } + controls.tree.append(view); + } + if (nodeId && action) restoreNodeFocus(nodeId, action); + } + + return { + /** @param {any} manifest */ + start(manifest) { + snapshotId = manifest.snapshotId; + for (const [select, values] of [[controls.owner, manifest.lookupFacets.ownerIds], [controls.lifecycle, manifest.lookupFacets.lifecycleStates]]) { + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + select.append(option); + } + } + void load(0, undefined, 0, []); + }, + cancel() { for (const branch of branches) branch.controller.abort(); }, + }; +} diff --git a/internal/command/requirementbrowser/assets/workspace-panels.js b/internal/command/requirementbrowser/assets/workspace-panels.js new file mode 100644 index 0000000..a67132c --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace-panels.js @@ -0,0 +1,98 @@ +// @ts-check + +/** @param {() => void} commitSelection */ +export function initializePanels(commitSelection) { + const navigation = document.querySelector("#workspace-navigation"); + const inspector = document.querySelector("#workspace-inspector"); + if (!(navigation instanceof HTMLDialogElement) || !(inspector instanceof HTMLDialogElement)) throw new Error("Missing workspace panels"); + const panels = {navigation, inspector}; + const mobile = window.matchMedia("(max-width: 64rem)"); + /** @type {Map} */ + const openers = new Map(); + + /** @param {HTMLElement | undefined} element */ + function visible(element) { + return element?.isConnected === true && element.getClientRects().length > 0; + } + + /** @param {HTMLDialogElement} panel @param {boolean} [restoreFocus] */ + function close(panel, restoreFocus = true) { + if (panel.open) panel.close(); + synchronize(); + if (restoreFocus) focusOpener(panel); + } + + /** @param {HTMLDialogElement} panel */ + function focusOpener(panel) { + const opener = openers.get(panel); + const fallback = document.querySelector("#open-inspector"); + if (visible(opener)) opener?.focus(); + else if (fallback instanceof HTMLElement && visible(fallback)) fallback.focus(); + } + + function synchronize() { + document.body.dataset.navigationOpen = String(panels.navigation.open); + document.body.dataset.inspectorOpen = String(panels.inspector.open); + document.body.dataset.modalOpen = String(mobile.matches && (panels.navigation.open || panels.inspector.open)); + for (const [name, panel] of Object.entries(panels)) { + document.querySelector(`[data-open-panel="${name}"]`)?.setAttribute("aria-expanded", String(panel.open)); + } + } + + /** @param {"navigation" | "inspector"} name @param {HTMLElement} opener */ + function open(name, opener) { + const panel = panels[name]; + if (name === "inspector") commitSelection(); + if (panel.open) { + close(panel); + return; + } + openers.set(panel, opener); + if (mobile.matches) { + for (const other of Object.values(panels)) close(other, false); + panel.showModal(); + } else { + panel.show(); + } + synchronize(); + const initial = panel.querySelector(name === "inspector" ? "#annotation-question" : "#requirement-search"); + if (initial instanceof HTMLElement) initial.focus(); + } + + for (const [name, panel] of Object.entries(panels)) { + const button = document.querySelector(`[data-open-panel="${name}"]`); + if (!(button instanceof HTMLButtonElement)) throw new Error("Missing panel opener"); + openers.set(panel, button); + if (name === "inspector") { + button.addEventListener("pointerdown", commitSelection); + button.addEventListener("focus", commitSelection); + } + button.addEventListener("click", () => open(/** @type {"navigation" | "inspector"} */ (name), button)); + panel.querySelector("[data-close-panel]")?.addEventListener("click", () => close(panel)); + panel.addEventListener("cancel", (event) => { + event.preventDefault(); + close(panel); + }); + panel.addEventListener("close", synchronize); + } + + function changeLayout() { + // Closing releases native modal/inert state before any desktop placement. + const focused = document.activeElement; + for (const panel of Object.values(panels)) close(panel, false); + if (!mobile.matches) { + // Layout is not user activation: show() would acquire native focus. + panels.navigation.open = true; + panels.inspector.open = true; + } + synchronize(); + if (focused instanceof HTMLElement && visible(focused)) focused.focus(); + else if (focused instanceof HTMLElement) { + const focusedPanel = Object.values(panels).find(panel => panel.contains(focused)); + if (focusedPanel) focusOpener(focusedPanel); + } + } + mobile.addEventListener("change", changeLayout); + changeLayout(); + return {closeNavigation: () => { if (mobile.matches) close(navigation); }}; +} diff --git a/internal/command/requirementbrowser/assets/workspace-requests.js b/internal/command/requirementbrowser/assets/workspace-requests.js new file mode 100644 index 0000000..900b18e --- /dev/null +++ b/internal/command/requirementbrowser/assets/workspace-requests.js @@ -0,0 +1,35 @@ +// @ts-check + +export class WorkspaceRequestError extends Error { + /** @param {number} status */ + constructor(status) { + super("Workspace request failed"); + this.status = status; + } +} + +/** @param {string} path @param {RequestInit} init @returns {Promise} */ +export async function fetchWorkspaceJSON(path, init) { + /** @type {string} */ + let body; + try { + const response = await fetch(path, init); + if (!response.ok) throw new WorkspaceRequestError(response.status); + body = await response.text(); + } catch (error) { + if (init.signal?.aborted || error instanceof WorkspaceRequestError) throw error; + throw new WorkspaceRequestError(0); + } + return JSON.parse(body); +} + +/** @param {unknown} error @param {boolean} optional */ +export function workspaceFailure(error, optional = false) { + const status = error instanceof WorkspaceRequestError ? error.status : -1; + if (status === 400) return {message: "The query could not be accepted. Check its fields and submit again.", action: "none", lock: false, kind: "correction"}; + if (status === 403) return {message: "Access to this workspace was denied.", action: "none", lock: true, kind: "denied"}; + if (status === 409) return {message: "The workspace snapshot has changed.", action: "reload", lock: true, kind: "stale"}; + if (status === 0 || status === 429 || status >= 500 && status <= 599) return {message: "The workspace could not be reached. Try this request again.", action: "retry", lock: false, kind: "retryable"}; + if (status === 404 && optional) return {message: "This workspace view is unavailable.", action: "none", lock: false, kind: "optional-unavailable"}; + return {message: "The admitted workspace is unavailable.", action: "none", lock: false, kind: "unavailable"}; +} diff --git a/internal/command/requirementbrowser/assets/workspace.css b/internal/command/requirementbrowser/assets/workspace.css index 4737e66..024658c 100644 --- a/internal/command/requirementbrowser/assets/workspace.css +++ b/internal/command/requirementbrowser/assets/workspace.css @@ -1,256 +1,182 @@ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 16px; --canvas: #ffffff; - --surface: #f2f4f7; - --active-surface: #d9e8ff; - --text: #172033; - --muted-text: #344054; - --border: #667085; + --surface: #f5f6f5; + --active-surface: #e0f0e8; + --text: #202522; + --muted-text: #4d5952; + --border: #77867d; --focus: #005fcc; + --accent: #17613d; } -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - background: var(--canvas); - color: var(--text); -} - +*, *::before, *::after { box-sizing: border-box; } +html { background: var(--canvas); color: var(--text); } body { - background: var(--canvas); - color: var(--text); + --navigation-width: 17rem; + --inspector-width: 20rem; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem); + grid-template-columns: var(--navigation-width) minmax(0, 1fr) var(--inspector-width); + grid-template-rows: auto 1fr; margin: 0; min-height: 100vh; + background: var(--canvas); + color: var(--text); } - -header, -main, -aside { - max-width: 100%; - min-width: 0; - padding: 1rem 1.5rem; -} - -header { - border-bottom: 2px solid var(--border); +body[data-navigation-open="false"] { --navigation-width: 0rem; } +body[data-inspector-open="false"] { --inspector-width: 0rem; } +header, main, .workspace-panel { min-width: 0; max-width: 100%; } +.product-bar { grid-column: 1 / -1; -} - -header p { - font-size: .8rem; - margin: 0; - text-transform: uppercase; -} - -h1 { - margin: .25rem 0; -} - -h1, -h2, -h3, -p, -li, -td, -th, -label, -button { - overflow-wrap: anywhere; -} - -nav { display: flex; - flex-wrap: wrap; - gap: .5rem; - margin-bottom: 1rem; - max-width: 100%; - min-width: 0; -} - -button, -textarea { - background: var(--surface); - border: 2px solid var(--border); + gap: .75rem; + align-items: center; + min-height: 4.25rem; + padding: .5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--canvas); + position: sticky; + top: 0; + z-index: 2; +} +.product-identity { flex: 1; min-width: 0; } +.product-identity strong { color: var(--accent); font-size: 1.125rem; } +h1 { margin: .125rem 0 0; font-size: .8125rem; font-weight: 500; color: var(--muted-text); } +h2 { font-size: 1.25rem; margin: 1rem 0; } +h3 { font-size: .875rem; margin: 1rem 0 .5rem; } +h1, h2, h3, p, li, td, th, label, button, summary { overflow-wrap: anywhere; } +p, li { line-height: 1.55; } +main { grid-column: 2; grid-row: 2; padding: 1.25rem 1.5rem 3rem; } +nav { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; min-width: 0; max-width: 100%; } +button, input, select, textarea { + background: var(--canvas); + border: 1px solid var(--border); + border-radius: 4px; color: var(--text); font: inherit; } - button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; min-block-size: 2.75rem; min-inline-size: 2.75rem; padding: .5rem .75rem; -} - -button:disabled { - color: var(--muted-text); - opacity: 1; -} - -button[aria-current="page"] { - background: var(--active-surface); - font-weight: 700; -} - -button:focus-visible, -textarea:focus-visible, -[tabindex="0"]:focus-visible { + font-size: .875rem; + cursor: pointer; +} +button:hover:not(:disabled) { background: var(--surface); } +button:disabled { color: var(--muted-text); opacity: 1; cursor: default; } +button[aria-current], button[aria-pressed="true"] { background: var(--active-surface); font-weight: 650; } +.icon-button { flex: 0 0 2.75rem; width: 2.75rem; height: 2.75rem; padding: .5rem; } +.workspace-icon { display: inline-block; flex: 0 0 1.25rem; width: 1.25rem; height: 1.25rem; min-width: 1.25rem; vertical-align: middle; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, [tabindex="0"]:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } - -article { - border-top: 2px solid var(--border); - min-width: 0; - padding: .75rem 0; -} - -aside { - border-left: 2px solid var(--border); -} - -textarea { - display: block; - min-height: 8rem; - width: 100%; -} - -#workspace-content, -#workspace-authority, -#handoff-output, -#selected-context, -[aria-label="Specification requirements"] { - max-width: 100%; - min-width: 0; -} - -#selected-context { - padding-left: 1.25rem; -} - -pre { +input, select { width: 100%; min-width: 0; min-height: 2.75rem; padding: .5rem; } +textarea { display: block; min-height: 9rem; width: 100%; padding: .75rem; resize: vertical; } +label { display: block; font-size: .8125rem; font-weight: 600; margin: 1rem 0 .375rem; } +summary { cursor: pointer; font-size: .8125rem; line-height: 1.65; } +summary .workspace-icon { margin-right: .375rem; } +.view-controls { gap: .375rem; } +.view-controls button { flex: 1 1 auto; } +#workspace-authority { margin: .75rem 0 1.5rem; color: var(--muted-text); font-size: .8125rem; } +#workspace-authority[open] { border-bottom: 1px solid var(--border); padding-bottom: 1rem; } +#workspace-authority h2 { font-size: .875rem; } +#workspace-content > h2 { margin: 1.25rem 0; } +[aria-label="Specification requirements"] { list-style: none; margin: 0; padding: 0; } +article { border-top: 1px solid var(--border); min-width: 0; padding: 1rem 0 1.25rem; } +article h3 { font-family: ui-monospace, monospace; color: var(--muted-text); font-size: .75rem; font-weight: 500; margin: 0; } +[data-anchor-id] { font-size: 1.0625rem; line-height: 1.65; margin: .75rem 0; max-inline-size: 75ch; } +.requirement-boundary { margin: .75rem 0; color: var(--muted-text); font-size: .8125rem; } +.page-summary { font-size: .8125rem; color: var(--muted-text); } +.workspace-panel { + position: sticky; + top: 4.25rem; + align-self: start; + width: auto; + max-height: calc(100dvh - 4.25rem); margin: 0; - max-width: 100%; - overflow-wrap: anywhere; - white-space: pre-wrap; -} - -svg { - display: block; - max-width: none; - min-width: 50rem; - width: 100%; -} - -.graph-viewport, -.table-viewport { - border: 2px solid var(--border); - max-width: 100%; - min-width: 0; + padding: 1.25rem 1rem; overflow: auto; -} - -.graph-viewport { - max-height: 42rem; -} - -.table-viewport { - margin-block: 1rem; -} - -[data-select-anchor][aria-pressed="true"] { - outline: 3px solid var(--focus); - outline-offset: 2px; -} - -svg rect { - fill: var(--surface); - stroke: var(--border); -} - -svg line { - stroke: var(--text); - stroke-width: 1.5; -} - -svg text { - fill: var(--text); - font-size: 13px; -} - -table { - border-collapse: collapse; - min-width: 100%; - width: max-content; -} - -th, -td { - border: 2px solid var(--border); - max-width: 24rem; - padding: .5rem; - text-align: left; -} + background: var(--surface); + color: var(--text); + border: 0; +} +.workspace-panel[open] { display: block; } +#workspace-navigation { grid-column: 1; grid-row: 2; border-right: 1px solid var(--border); } +#workspace-inspector { grid-column: 3; grid-row: 2; border-left: 1px solid var(--border); } +.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; } +.panel-heading h2 { font-size: 1rem; margin: 0; } +.search-field { display: flex; gap: .375rem; } +#reset-filters { margin-top: .75rem; } +.navigation-nodes { margin: .5rem 0; padding: 0 0 0 .375rem; list-style: none; border-left: 1px solid var(--border); } +.navigation-nodes .navigation-nodes .navigation-nodes { padding-left: 0; border-left: 0; } +.navigation-row { display: flex; align-items: start; min-width: 0; } +.navigation-row button { border-color: transparent; background: transparent; } +.navigation-row button[aria-current] { background: var(--active-surface); } +.navigation-label { justify-content: start; text-align: left; min-width: 0; flex: 1; padding: .625rem .375rem; } +.navigation-label .workspace-icon { display: none; } +.navigation-paging { display: flex; gap: .375rem; margin: .25rem 0; } +#spec-navigation p, #selected-scope p { font-size: .8125rem; } +#workspace-content, #workspace-authority, #handoff-output, #selected-context { min-width: 0; max-width: 100%; } +#selected-context { padding-left: 1.25rem; font-size: .875rem; } +#submit-question { margin-top: .75rem; } +#handoff-status { font-size: .8125rem; } +#handoff-packet { font-size: .75rem; } +pre { margin: 0; max-width: 100%; overflow-wrap: anywhere; white-space: pre-wrap; } +.graph-viewport, .table-viewport { border: 1px solid var(--border); min-width: 0; max-width: 100%; overflow: auto; } +.graph-viewport { max-height: 42rem; } +.table-viewport { margin-block: 1rem; } +.graph-viewport svg { display: block; min-width: 50rem; width: 100%; max-width: none; } +[data-select-anchor][aria-pressed="true"] { outline: 3px solid var(--focus); outline-offset: 2px; } +.graph-viewport svg rect { fill: var(--surface); stroke: var(--border); } +.graph-viewport svg line { stroke: var(--text); stroke-width: 1.5; } +.graph-viewport svg text { fill: var(--text); font-size: 13px; } +table { border-collapse: collapse; min-width: 100%; width: max-content; } +th, td { border: 1px solid var(--border); max-width: 24rem; padding: .5rem; text-align: left; } @media (prefers-color-scheme: dark) { :root { - --canvas: #101828; - --surface: #1d2939; - --active-surface: #344054; - --text: #f2f4f7; - --muted-text: #d0d5dd; - --border: #98a2b3; + --canvas: #181c19; + --surface: #222824; + --active-surface: #30483b; + --text: #f0f4f1; + --muted-text: #c4cec7; + --border: #87998e; --focus: #84adff; + --accent: #a1dbba; } } - -@media (max-width: 48rem) { - body { - display: block; - } - - aside { - border-left: 0; - border-top: 2px solid var(--border); +@media (max-width: 64rem) { + body { grid-template-columns: minmax(0, 1fr); } + body[data-modal-open="true"] { overflow: hidden; } + main { grid-column: 1; padding: 1rem 1rem 3rem; } + .workspace-panel { + position: fixed; + inset: 0 auto 0 0; + width: min(24rem, calc(100% - 1rem)); + max-width: calc(100% - 1rem); + height: 100dvh; + max-height: 100dvh; + padding: 1rem; } + #workspace-inspector { inset: 0 0 0 auto; } + .workspace-panel::backdrop { background: #00000080; } + .view-controls button { flex: 1 1 auto; min-width: 0; padding: .5rem .375rem; } + .view-controls .workspace-icon { display: none; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto; } } - @media (forced-colors: active) { - header, - aside, - article, - button, - textarea, - .graph-viewport, - .table-viewport, - th, - td { - border-color: CanvasText; - } - - button:focus-visible, - textarea:focus-visible, - [tabindex="0"]:focus-visible, - [data-select-anchor][aria-pressed="true"] { - outline-color: Highlight; - } - - svg rect { - fill: Canvas; - stroke: CanvasText; - } - - svg line { - stroke: CanvasText; - } - - svg text { - fill: CanvasText; - } + .product-bar, .workspace-panel, article, button, input, select, textarea, .graph-viewport, .table-viewport, th, td { border-color: CanvasText; } + button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, [tabindex="0"]:focus-visible, [data-select-anchor][aria-pressed="true"] { outline-color: Highlight; } + .graph-viewport svg rect { fill: Canvas; stroke: CanvasText; } + .graph-viewport svg line { stroke: CanvasText; } + .graph-viewport svg text { fill: CanvasText; } } diff --git a/internal/command/requirementbrowser/assets/workspace.js b/internal/command/requirementbrowser/assets/workspace.js index b35dcde..745e3f0 100644 --- a/internal/command/requirementbrowser/assets/workspace.js +++ b/internal/command/requirementbrowser/assets/workspace.js @@ -1,6 +1,10 @@ // @ts-check import {emptySelectionState, transitionSelection} from "./selection-authority.js"; +import {decorateIcons, icon} from "./workspace-icons.js"; +import {initializePanels} from "./workspace-panels.js"; +import {initializeNavigation} from "./workspace-navigation.js"; +import {fetchWorkspaceJSON, workspaceFailure} from "./workspace-requests.js"; export {}; @@ -30,19 +34,15 @@ let requestSequence = 0; /** @type {AbortController | null} */ let activeViewController = null; let selectionState = emptySelectionState(); +let requestsLocked = false; +let handoffPending = false; +/** @type {import("./workspace-navigation.js").LookupFilters} */ +let activeFilters = Object.freeze({}); /** @param {string} prefix */ function nextRequestId(prefix) { requestSequence += 1; - activeRequestId = `${prefix}.${requestSequence.toString(36)}`; - return activeRequestId; -} - -/** @param {string} path @param {RequestInit} init @returns {Promise} */ -async function fetchJSON(path, init) { - const response = await fetch(path, init); - if (!response.ok) throw new Error(`Workspace request failed: ${response.status}`); - return response.json(); + return `${prefix}.${requestSequence.toString(36)}`; } /** @param {string} state */ @@ -62,36 +62,65 @@ function setActiveView(activeView) { } } -function enableViewControls() { - for (const control of document.querySelectorAll("[data-view]")) { - if (control instanceof HTMLButtonElement) control.disabled = false; +function handoffUnavailable() { + return manifest === null || requestsLocked || handoffPending; +} + +function reconcileRequestControls() { + for (const control of document.querySelectorAll("[data-protected-request]")) { + if (control instanceof HTMLButtonElement || control instanceof HTMLInputElement || control instanceof HTMLSelectElement) { + control.disabled = control === submit ? handoffUnavailable() : requestsLocked; + } } } +/** @param {"specifications" | "diff" | "graph"} state */ +function completeContentView(state) { + reconcileRequestControls(); + content.setAttribute("aria-busy", "false"); + setWorkspaceState(state); +} + async function initializeWorkspace() { + if (requestsLocked) return; + activeViewController?.abort(); + activeViewController = new AbortController(); + const signal = activeViewController.signal; + const requestId = nextRequestId("browser.bootstrap"); + activeRequestId = requestId; + setWorkspaceState("bootstrap-loading"); + const heading = document.createElement("h2"); + heading.textContent = "Loading workspace"; + const status = document.createElement("p"); + status.setAttribute("role", "status"); + status.textContent = "Loading admitted manifest..."; + content.replaceChildren(heading, status); + content.setAttribute("aria-busy", "true"); try { - manifest = await fetchJSON("/api/v1/manifest", {headers: {"X-Proofkit-Browser-Capability": capability}}); + const response = await fetchWorkspaceJSON("/api/v1/manifest", {headers: {"X-Proofkit-Browser-Capability": capability}, signal}); + if (signal.aborted || requestId !== activeRequestId) return; + manifest = response; authorityTextView.textContent = `Authority: ${manifest.authority}. Snapshot: ${manifest.snapshotId}. Expected-digest coverage: ${manifest.expectedDigestCoverage}.`; appendTextItems(authorityNonClaimsView, manifest.nonClaims ?? []); - enableViewControls(); + reconcileRequestControls(); + navigation.start(manifest); await renderSpecifications(); - } catch { + } catch (error) { + if (signal.aborted || requestId !== activeRequestId) return; activeViewController?.abort(); content.replaceChildren(); content.setAttribute("aria-busy", "false"); const heading = document.createElement("h2"); heading.textContent = "Workspace unavailable"; - const alert = document.createElement("p"); - alert.setAttribute("role", "alert"); - alert.textContent = "The admitted workspace could not be loaded."; - content.append(heading, alert); + content.append(heading); + showFailure(error, content, () => void initializeWorkspace()); setWorkspaceState("bootstrap-failed"); } } /** @param {string} path @param {any} body @param {AbortSignal} [signal] @returns {Promise} */ async function post(path, body, signal) { - return fetchJSON(path, {method: "POST", headers, body: JSON.stringify(body), signal}); + return fetchWorkspaceJSON(path, {method: "POST", headers, body: JSON.stringify(body), signal}); } /** @param {string} title @param {string} requestPrefix @param {"specifications" | "diff" | "graph"} view */ @@ -99,7 +128,9 @@ function beginView(title, requestPrefix, view) { activeViewController?.abort(); activeViewController = new AbortController(); const requestId = nextRequestId(requestPrefix); + activeRequestId = requestId; clearSelection(); + packetView.replaceChildren(); setActiveView(view); setWorkspaceState(`${view}-loading`); content.replaceChildren(); @@ -115,14 +146,42 @@ function beginView(title, requestPrefix, view) { return {requestId, signal: activeViewController.signal, status}; } -/** @param {HTMLElement} status */ -function failView(status) { +/** @param {unknown} error @param {HTMLElement} container @param {() => void} retry @param {boolean} [optional] */ +function showFailure(error, container, retry, optional = false) { + const failure = workspaceFailure(error, optional); + if (failure.lock) { + requestsLocked = true; + reconcileRequestControls(); + navigation.cancel(); + } + const message = document.createElement("p"); + message.setAttribute("role", "alert"); + message.dataset.state = failure.kind; + message.textContent = failure.message; + container.append(message); + if (failure.action === "none") return; + const action = document.createElement("button"); + action.type = "button"; + if (failure.action === "retry") { + action.dataset.protectedRequest = ""; + action.disabled = requestsLocked; + } + action.append(icon("refresh-cw"), document.createTextNode(failure.action === "retry" ? "Retry" : "Reload workspace")); + action.addEventListener("click", () => { + if (!action.isConnected) return; + if (failure.action === "reload") window.location.reload(); + else if (!requestsLocked) retry(); + }); + container.append(action); +} + +/** @param {HTMLElement} status @param {unknown} error @param {() => void} retry @param {boolean} [optional] */ +function failView(status, error, retry, optional = false) { content.setAttribute("aria-busy", "false"); setWorkspaceState("view-failed"); - status.setAttribute("role", "alert"); - status.setAttribute("aria-live", "assertive"); - status.dataset.state = "failed"; - status.textContent = "The admitted workspace view could not be loaded."; + status.remove(); + const failedRequestId = activeRequestId; + showFailure(error, content, () => { if (activeRequestId === failedRequestId) retry(); }, optional); } /** @param {any} response @param {string} requestId @param {AbortSignal} signal */ @@ -134,14 +193,17 @@ function admitCurrentViewResponse(response, requestId, signal) { return true; } -/** @param {number} [offset] */ -async function renderSpecifications(offset = 0) { +/** @param {number} [offset] @param {import("./workspace-navigation.js").LookupFilters} [filters] @param {number[]} [history] */ +async function renderSpecifications(offset = 0, filters = activeFilters, history = []) { + if (requestsLocked) return; + activeFilters = filters; + const query = Object.freeze({...filters, maxRecords: 64, offset}); const {requestId, signal, status} = beginView("Specifications", "browser.specifications", "specifications"); try { const response = await post("/api/v1/requirements", { requestId, snapshotId: manifest.snapshotId, - query: {maxRecords: 256, offset}, + query, }, signal); if (!admitCurrentViewResponse(response, requestId, signal)) return; status.remove(); @@ -155,14 +217,18 @@ async function renderSpecifications(offset = 0) { article.dataset.requirementId = requirement.requirementId; const title = document.createElement("h3"); title.textContent = requirement.requirementId; - const boundary = document.createElement("section"); + const boundary = document.createElement("details"); boundary.className = "requirement-boundary"; boundary.setAttribute("aria-label", `Boundary for ${requirement.requirementId}`); - const ownership = document.createElement("p"); + const ownership = document.createElement("summary"); ownership.textContent = `Owner: ${requirement.ownerId}. Claim level: ${requirement.claimLevel}.`; - const nonClaims = document.createElement("ul"); - appendTextItems(nonClaims, [...(requirement.sourceNonClaims ?? []), ...(requirement.nonClaims ?? [])]); - boundary.append(ownership, nonClaims); + boundary.append(ownership); + boundary.addEventListener("toggle", () => { + if (!boundary.open || boundary.querySelector("ul")) return; + const nonClaims = document.createElement("ul"); + appendTextItems(nonClaims, [...(requirement.sourceNonClaims ?? []), ...(requirement.nonClaims ?? [])]); + boundary.append(nonClaims); + }); const invariant = document.createElement("p"); const anchorId = requirement.anchor.anchorId; invariant.dataset.anchorId = anchorId; @@ -171,14 +237,14 @@ async function renderSpecifications(offset = 0) { choose.type = "button"; choose.dataset.selectAnchor = anchorId; choose.setAttribute("aria-pressed", "false"); - choose.textContent = "Select invariant"; + choose.append(icon("check"), document.createTextNode("Select invariant")); choose.addEventListener("click", () => { for (const control of content.querySelectorAll("[data-select-anchor]")) control.setAttribute("aria-pressed", "false"); choose.setAttribute("aria-pressed", "true"); selectionState = transitionSelection(selectionState, {kind: "button", targets: [{anchorId, exactQuote: requirement.invariant, startCodePoint: 0, endCodePoint: [...requirement.invariant].length}]}); announceSelection(); }); - article.append(title, boundary, invariant, choose); + article.append(title, invariant, boundary, choose); item.append(article); list.append(item); itemIndex += 1; @@ -187,22 +253,21 @@ async function renderSpecifications(offset = 0) { status.dataset.state = "no-match"; status.textContent = "No requirements matched the admitted query."; content.append(status); - content.setAttribute("aria-busy", "false"); - setWorkspaceState("specifications"); + completeContentView("specifications"); return; } content.append(list); - appendPagingControls("specifications", offset, response.projection.selectedRequirementCount ?? 0, response.projection.availableRequirementCount ?? 0); - content.setAttribute("aria-busy", "false"); - setWorkspaceState("specifications"); + appendRequirementPaging(offset, response.projection.selectedRequirementCount ?? 0, response.projection.matchingRequirementCount ?? 0, filters, history); + completeContentView("specifications"); } catch (error) { - if (signal.aborted) return; - failView(status); + if (signal.aborted || requestId !== activeRequestId) return; + failView(status, error, () => void renderSpecifications(offset, filters, history)); } } /** @param {number} [offset] */ async function renderDiff(offset = 0) { + if (requestsLocked) return; const {requestId, signal, status} = beginView("Semantic diff", "browser.diff", "diff"); if (!manifest.diffAvailable) { content.setAttribute("aria-busy", "false"); @@ -234,16 +299,16 @@ async function renderDiff(offset = 0) { content.append(article); } appendPagingControls("diff", offset, response.projection.selectedChangeCount ?? 0, response.projection.availableChangeCount ?? 0); - content.setAttribute("aria-busy", "false"); - setWorkspaceState("diff"); + completeContentView("diff"); } catch (error) { - if (signal.aborted) return; - failView(status); + if (signal.aborted || requestId !== activeRequestId) return; + failView(status, error, () => void renderDiff(offset), true); } } /** @param {number} [offset] */ async function renderGraph(offset = 0, edgeOffset = 0) { + if (requestsLocked) return; const {requestId, signal, status} = beginView("Traceability graph", "browser.graph", "graph"); if (!manifest.graphAvailable) { content.setAttribute("aria-busy", "false"); @@ -320,15 +385,36 @@ async function renderGraph(offset = 0, edgeOffset = 0) { )); appendPagingControls("graph", offset, graph.primaryNodeCount ?? 0, graph.availableNodeCount ?? 0); appendGraphEdgeControls(offset, edgeOffset, graph.selectedEdgeCount ?? 0, graph.availableIncidentEdgeCount ?? 0); - content.setAttribute("aria-busy", "false"); - setWorkspaceState("graph"); + completeContentView("graph"); } catch (error) { - if (signal.aborted) return; - failView(status); + if (signal.aborted || requestId !== activeRequestId) return; + failView(status, error, () => void renderGraph(offset, edgeOffset), true); } } -/** @param {"specifications" | "diff" | "graph"} view @param {number} offset @param {number} selectedCount @param {number} availableCount */ +/** @param {number} offset @param {number} selected @param {number} matching @param {import("./workspace-navigation.js").LookupFilters} filters @param {number[]} history */ +function appendRequirementPaging(offset, selected, matching, filters, history) { + const summary = document.createElement("p"); + summary.className = "page-summary"; + summary.textContent = `Showing ${selected === 0 ? 0 : offset + 1}-${offset + selected} of ${matching} specifications records.`; + content.append(summary); + const controls = document.createElement("nav"); + controls.setAttribute("aria-label", "specifications pages"); + /** @param {string} label @param {string} symbol @param {() => void} action */ + function add(label, symbol, action) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.protectedRequest = ""; + button.append(icon(symbol), document.createTextNode(label)); + button.addEventListener("click", action); + controls.append(button); + } + if (history.length > 0) add("Previous specifications page", "arrow-left", () => void renderSpecifications(history.at(-1) ?? 0, filters, history.slice(0, -1))); + if (offset + selected < matching) add("Next specifications page", "arrow-right", () => void renderSpecifications(offset + selected, filters, [...history, offset])); + content.append(controls); +} + +/** @param {"diff" | "graph"} view @param {number} offset @param {number} selectedCount @param {number} availableCount */ function appendPagingControls(view, offset, selectedCount, availableCount) { const summary = document.createElement("p"); const first = selectedCount === 0 ? 0 : offset + 1; @@ -341,15 +427,17 @@ function appendPagingControls(view, offset, selectedCount, availableCount) { if (offset > 0) { const previous = document.createElement("button"); previous.type = "button"; + previous.dataset.protectedRequest = ""; previous.textContent = `Previous ${view} page`; - previous.addEventListener("click", () => void (view === "diff" ? renderDiff(Math.max(0, offset - pageSize)) : view === "graph" ? renderGraph(Math.max(0, offset - pageSize)) : renderSpecifications(Math.max(0, offset - pageSize)))); + previous.addEventListener("click", () => void (view === "diff" ? renderDiff(Math.max(0, offset - pageSize)) : renderGraph(Math.max(0, offset - pageSize)))); controls.append(previous); } if (offset + selectedCount < availableCount) { const next = document.createElement("button"); next.type = "button"; + next.dataset.protectedRequest = ""; next.textContent = `Next ${view} page`; - next.addEventListener("click", () => void (view === "diff" ? renderDiff(offset + selectedCount) : view === "graph" ? renderGraph(offset + selectedCount) : renderSpecifications(offset + selectedCount))); + next.addEventListener("click", () => void (view === "diff" ? renderDiff(offset + selectedCount) : renderGraph(offset + selectedCount))); controls.append(next); } content.append(controls); @@ -367,6 +455,7 @@ function appendGraphEdgeControls(nodeOffset, edgeOffset, selectedCount, availabl if (edgeOffset > 0) { const previous = document.createElement("button"); previous.type = "button"; + previous.dataset.protectedRequest = ""; previous.textContent = "Previous graph relation page"; previous.addEventListener("click", () => void renderGraph(nodeOffset, Math.max(0, edgeOffset - 2048))); controls.append(previous); @@ -374,6 +463,7 @@ function appendGraphEdgeControls(nodeOffset, edgeOffset, selectedCount, availabl if (edgeOffset + selectedCount < availableCount) { const next = document.createElement("button"); next.type = "button"; + next.dataset.protectedRequest = ""; next.textContent = "Next graph relation page"; next.addEventListener("click", () => void renderGraph(nodeOffset, edgeOffset + selectedCount)); controls.append(next); @@ -541,7 +631,7 @@ for (const control of [questionInput, submit]) { } clearSelectionButton.addEventListener("click", clearSelection); submit.addEventListener("click", async () => { - if (submit.disabled) return; + if (submit.disabled || handoffUnavailable()) return; const question = questionInput.value.trim(); if (selectionState.targets.length === 0 || !question) { status.setAttribute("role", "status"); @@ -550,7 +640,8 @@ submit.addEventListener("click", async () => { return; } const submissionViewRequestId = activeRequestId; - submit.disabled = true; + handoffPending = true; + reconcileRequestControls(); status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); status.textContent = "Creating handoff packet..."; @@ -566,9 +657,19 @@ submit.addEventListener("click", async () => { status.textContent = "The handoff packet could not be created."; if (submissionViewRequestId === activeRequestId) setWorkspaceState("handoff-failed"); } finally { - submit.disabled = false; + handoffPending = false; + reconcileRequestControls(); } }); +decorateIcons(document); +const panels = initializePanels(commitSelection); +const navigation = initializeNavigation({ + post, + nextRequestId, + select(filters) { void renderSpecifications(0, filters); panels.closeNavigation(); }, + fail: showFailure, + locked: () => requestsLocked, +}); announceSelection(); void initializeWorkspace(); diff --git a/internal/command/requirementbrowser/http_handler.go b/internal/command/requirementbrowser/http_handler.go index 0398709..03bc096 100644 --- a/internal/command/requirementbrowser/http_handler.go +++ b/internal/command/requirementbrowser/http_handler.go @@ -59,6 +59,14 @@ func browserHandler(view string, rendered renderedView, expectedAuthority, capab serveWorkspaceAsset(response, method, workspaceJavaScript, "text/javascript; charset=utf-8") case "/assets/selection-authority.js": serveWorkspaceAsset(response, method, selectionAuthorityJavaScript, "text/javascript; charset=utf-8") + case "/assets/workspace-icons.js": + serveWorkspaceAsset(response, method, workspaceIconsJavaScript, "text/javascript; charset=utf-8") + case "/assets/workspace-panels.js": + serveWorkspaceAsset(response, method, workspacePanelsJavaScript, "text/javascript; charset=utf-8") + case "/assets/workspace-requests.js": + serveWorkspaceAsset(response, method, workspaceRequestsJavaScript, "text/javascript; charset=utf-8") + case "/assets/workspace-navigation.js": + serveWorkspaceAsset(response, method, workspaceNavigationJavaScript, "text/javascript; charset=utf-8") case "/assets/workspace.css": serveWorkspaceAsset(response, method, workspaceCSS, "text/css; charset=utf-8") case "/api/v1/manifest": @@ -81,7 +89,7 @@ func browserHandler(view string, rendered renderedView, expectedAuthority, capab } defer releaseWorkspaceRequest(workspaceRequests) serveWorkspaceQuery(response, request, expectedOrigin, capability, rendered.workspace) - case "/api/v1/requirements": + case "/api/v1/requirements", "/api/v1/navigation": if method != http.MethodPost { methodNotAllowed(response, method, "POST") return @@ -149,24 +157,28 @@ func serveWorkspaceRequirements(response http.ResponseWriter, request *http.Requ writeAPIError(response, request.Method, err) return } - query, err := admitProjectionQuery(record["query"]) + var page workspacePage + if request.URL.Path == "/api/v1/navigation" { + query, queryErr := admitWorkspaceNavigationQuery(record["query"], session.Lookup) + if queryErr != nil { + writeAPIError(response, request.Method, queryErr) + return + } + page = workspaceNavigationPage(session.Lookup, query) + } else { + query, queryErr := admitWorkspaceLookupQuery(record["query"], session.Lookup) + if queryErr != nil { + writeAPIError(response, request.Method, queryErr) + return + } + page = workspaceLookupPage(session.Lookup, query) + } + body, err := page.encode(requestID, session.SnapshotID, maxWorkspaceLookupResponseBytes) if err != nil { writeAPIError(response, request.Method, err) return } - projection, state := requirementWindow(session.Requirements, query) - serveWorkspaceJSON(response, request.Method, map[string]any{"projection": projection, "requestId": requestID, "schemaVersion": json.Number("2"), "snapshotId": session.SnapshotID, "state": state}) -} - -func requirementWindow(requirements []any, query projectionQuery) (map[string]any, string) { - start := min(query.Offset, len(requirements)) - end := min(start+query.MaxRecords, len(requirements)) - selected := append([]any{}, requirements[start:end]...) - state := "complete" - if start > 0 || end < len(requirements) { - state = "partial_with_omissions" - } - return map[string]any{"authority": "lookup_fragment_only", "availableRequirementCount": len(requirements), "omittedRequirementCount": len(requirements) - len(selected), "projectionKind": "proofkit.requirement-browser-requirement-fragment", "requirements": selected, "selectedRequirementCount": len(selected)}, state + serveWorkspaceJSONBytes(response, request.Method, body) } func serveWorkspaceQuery(response http.ResponseWriter, request *http.Request, expectedOrigin, capability string, session *workspaceSession) { @@ -543,6 +555,10 @@ func serveWorkspaceJSON(response http.ResponseWriter, method string, value any) response.WriteHeader(http.StatusInternalServerError) return } + serveWorkspaceJSONBytes(response, method, body) +} + +func serveWorkspaceJSONBytes(response http.ResponseWriter, method string, body []byte) { response.Header().Set("cache-control", "no-store") response.Header().Set("content-type", "application/json; charset=utf-8") setWorkspaceSecurityHeaders(response) diff --git a/internal/command/requirementbrowser/http_handler_test.go b/internal/command/requirementbrowser/http_handler_test.go index 9fc562f..0256c70 100644 --- a/internal/command/requirementbrowser/http_handler_test.go +++ b/internal/command/requirementbrowser/http_handler_test.go @@ -1,12 +1,15 @@ package requirementbrowser import ( + "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" ) func TestWriteAPIErrorUsesTypedClassification(t *testing.T) { @@ -53,20 +56,29 @@ func TestWorkspaceRequestAdmissionIsBounded(t *testing.T) { func TestProjectionQueryKeepsEveryAdmittedRequirementPageReachable(t *testing.T) { const finalOffset = 20_224 - requirements := make([]any, finalOffset+1) - for index := range requirements { - requirements[index] = map[string]any{"requirementId": fmt.Sprintf("REQ-%05d", index)} + index := workspaceLookupIndex{Rows: make([]workspaceRequirement, finalOffset+1)} + for position := range index.Rows { + index.Rows[position].Requirement.RequirementID = fmt.Sprintf("REQ-%05d", position) } - query, err := admitProjectionQuery(map[string]any{ + query, err := admitWorkspaceLookupQuery(map[string]any{ "maxRecords": json.Number("256"), "offset": json.Number("20224"), - }) + }, index) if err != nil { t.Fatalf("admit final reachable page: %v", err) } - projection, state := requirementWindow(requirements, query) - if state != "partial_with_omissions" || projection["selectedRequirementCount"] != 1 { - t.Fatalf("final page state=%s projection=%#v", state, projection) + body, err := workspaceLookupPage(index, query).encode("page.test", "snapshot.test", maxWorkspaceLookupResponseBytes) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatal(err) + } + response := value.(map[string]any) + projection := response["projection"].(map[string]any) + if response["state"] != "partial_with_omissions" || projection["selectedRequirementCount"] != json.Number("1") { + t.Fatalf("final page lost exact selection: %#v", projection) } selected := projection["requirements"].([]any) if got := selected[0].(map[string]any)["requirementId"]; got != "REQ-20224" { diff --git a/internal/command/requirementbrowser/workspace.go b/internal/command/requirementbrowser/workspace.go index 578a346..82c2b5e 100644 --- a/internal/command/requirementbrowser/workspace.go +++ b/internal/command/requirementbrowser/workspace.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "html" - "sort" "strings" "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" @@ -24,13 +23,13 @@ type workspaceAnchor struct { } type workspaceSession struct { - Anchors map[string]workspaceAnchor - Diff map[string]any - Graph map[string]any - Manifest map[string]any - Requirements []any - Snapshot requirementcontext.Snapshot - SnapshotID string + Anchors map[string]workspaceAnchor + Diff map[string]any + Graph map[string]any + Manifest map[string]any + Lookup workspaceLookupIndex + Snapshot requirementcontext.Snapshot + SnapshotID string } func buildWorkspace(raw any) (workspaceSession, string, error) { @@ -52,10 +51,7 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { if err != nil { return workspaceSession{}, "", err } - anchors, requirements, err := workspaceRequirements(snapshot) - if err != nil { - return workspaceSession{}, "", err - } + lookup, anchors := buildWorkspaceLookupIndex(snapshot) var diff map[string]any if record["diffInput"] != nil { diff, err = requirementdiff.Build(record["diffInput"]) @@ -94,12 +90,13 @@ func buildWorkspace(raw any) (workspaceSession, string, error) { "expectedDigestCoverage": snapshot.ExpectedDigestCoverage, "graphAvailable": graph != nil, "nonClaims": admit.StringSliceToAny(serverNonClaims), - "requirementCount": len(requirements), + "requirementCount": len(lookup.Rows), + "lookupFacets": map[string]any{"ownerIds": workspaceSortedSet(lookup.Owners), "lifecycleStates": workspaceSortedSet(lookup.LifecycleStates)}, "schemaVersion": json.Number("2"), "snapshotId": snapshot.SnapshotID, "workspaceId": workspaceID, } - return workspaceSession{Anchors: anchors, Diff: diff, Graph: graph, Manifest: manifest, Requirements: requirements, Snapshot: snapshot, SnapshotID: snapshot.SnapshotID}, workspaceHTML(workspaceID), nil + return workspaceSession{Anchors: anchors, Diff: diff, Graph: graph, Manifest: manifest, Lookup: lookup, Snapshot: snapshot, SnapshotID: snapshot.SnapshotID}, workspaceHTML(workspaceID), nil } func admitWorkspaceInputVersion(record map[string]any) error { @@ -192,57 +189,6 @@ func sameStringSet(left, right map[string]struct{}) bool { return true } -func workspaceRequirements(snapshot requirementcontext.Snapshot) (map[string]workspaceAnchor, []any, error) { - rawSources, ok := snapshot.Projections["requirementSources"].([]any) - if !ok { - return nil, nil, fmt.Errorf("requirement browser workspace requires requirement source projections") - } - digestBySource := map[string]string{} - for _, source := range snapshot.Sources { - if source.Kind == "requirement_source" { - digestBySource[source.SourceRef] = source.CurrentDigest - } - } - anchors := map[string]workspaceAnchor{} - requirements := []any{} - for sourceIndex, rawSource := range rawSources { - source, ok := rawSource.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("requirement browser workspace source projection is invalid") - } - sourceID, _ := source["sourceId"].(string) - digest := digestBySource[sourceID] - rawRequirements, ok := source["requirements"].([]any) - if !ok { - return nil, nil, fmt.Errorf("requirement browser workspace requirements projection is invalid") - } - for requirementIndex, rawRequirement := range rawRequirements { - requirement, ok := rawRequirement.(map[string]any) - if !ok { - return nil, nil, fmt.Errorf("requirement browser workspace requirement projection is invalid") - } - id, _ := requirement["requirementId"].(string) - invariant, _ := requirement["invariant"].(string) - anchorID := "requirement:" + id + ":invariant" - anchor := workspaceAnchor{AnchorID: anchorID, JSONPointer: fmt.Sprintf("/projections/requirementSources/%d/requirements/%d/invariant", sourceIndex, requirementIndex), RequirementID: id, SourceDigest: digest, Text: invariant} - anchors[anchorID] = anchor - requirements = append(requirements, map[string]any{ - "anchor": anchorValue(anchor), - "claimLevel": requirement["claimLevel"], - "invariant": invariant, - "nonClaims": requirement["nonClaims"], - "ownerId": requirement["ownerId"], - "requirementId": id, - "sourceNonClaims": source["nonClaims"], - }) - } - } - sort.Slice(requirements, func(left, right int) bool { - return requirements[left].(map[string]any)["requirementId"].(string) < requirements[right].(map[string]any)["requirementId"].(string) - }) - return anchors, requirements, nil -} - func anchorValue(anchor workspaceAnchor) map[string]any { return map[string]any{"anchorId": anchor.AnchorID, "jsonPointer": anchor.JSONPointer, "requirementId": anchor.RequirementID, "sourceDigest": anchor.SourceDigest} } @@ -254,10 +200,23 @@ func workspaceHTML(workspaceID string) string { "", "" + html.EscapeString(workspaceID) + " - Proofkit workspace", "", - "

Proofkit semantic workspace

" + html.EscapeString(workspaceID) + "

Authority boundary

Loading admitted authority...

    ", - "
    ", - "

    Loading workspace

    Loading admitted manifest...

    ", - "", + `
    + +
    Proofkit

    ` + html.EscapeString(workspaceID) + `

    +
    +
    + +

    Specification hierarchy

    +
    +
    +
    Derived view

    Authority boundary

    Loading admitted authority...

      +

      Loading workspace

      Loading admitted manifest...

      +

      Ask about selection

      +

      Selected source text

        +

        Handoff packet

        `, "\n", }, "") } diff --git a/internal/command/requirementbrowser/workspace_http_capacity_test.go b/internal/command/requirementbrowser/workspace_http_capacity_test.go new file mode 100644 index 0000000..1066cab --- /dev/null +++ b/internal/command/requirementbrowser/workspace_http_capacity_test.go @@ -0,0 +1,136 @@ +package requirementbrowser + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func TestWorkspaceNavigationHTTPAccountsForFullAndRemainderWindows(t *testing.T) { + fixture := workspaceLookupFixture(t) + contextValue := fixture["context"].(map[string]any) + tree := contextValue["projections"].(map[string]any)["specTree"].(map[string]any) + for index := 129; index >= 0; index-- { + id := fmt.Sprintf("spec.sibling-%03d", index) + tree["nodes"] = append(tree["nodes"].([]any), map[string]any{ + "nodeId": id, "label": "Independent sibling", "nodeKind": "module_spec", + "displayOrder": json.Number(fmt.Sprint(index + 100)), "callerAnnotations": []any{}, + "sourceRefs": []any{map[string]any{"sourceRefId": id + ".requirements", "sourceRefKind": "source_id", "sourceRole": "requirements", "sourceId": "consumer.a"}}, + }) + tree["edges"] = append(tree["edges"].([]any), map[string]any{"parentNodeId": "spec.root", "childNodeId": id}) + } + resignWorkspaceSnapshot(t, contextValue) + handle, capability := startWorkspaceTestServer(t, fixture, false) + first := []string{"spec.child"} + for index := 0; index < 127; index++ { + first = append(first, fmt.Sprintf("spec.sibling-%03d", index)) + } + for _, item := range []struct { + name string + query map[string]any + ids []string + available int + selected int + omitted int + state string + }{ + {"root", map[string]any{}, []string{"spec.root"}, 1, 1, 0, "complete"}, + {"full window", map[string]any{"parentNodeId": "spec.root", "maxRecords": json.Number("128")}, first, 131, 128, 3, "partial_with_omissions"}, + {"remainder", map[string]any{"parentNodeId": "spec.root", "maxRecords": json.Number("128"), "offset": json.Number("128")}, []string{"spec.sibling-127", "spec.sibling-128", "spec.sibling-129"}, 131, 3, 128, "partial_with_omissions"}, + {"offset at end", map[string]any{"parentNodeId": "spec.root", "offset": json.Number("131")}, []string{}, 131, 0, 131, "partial_with_omissions"}, + {"leaf", map[string]any{"parentNodeId": "spec.sibling-000"}, []string{}, 0, 0, 0, "complete"}, + } { + t.Run(item.name, func(t *testing.T) { + response := postWorkspaceJSON(t, handle.URL+"api/v1/navigation", capability, map[string]any{"requestId": "navigation.capacity", "snapshotId": handle.SnapshotID, "query": item.query}) + projection := response["projection"].(map[string]any) + assertWorkspaceRowIDs(t, projection["nodes"], "nodeId", item.ids) + for field, expected := range map[string]int{"availableNodeCount": item.available, "selectedNodeCount": item.selected, "omittedNodeCount": item.omitted} { + if projection[field] != json.Number(fmt.Sprint(expected)) { + t.Fatalf("navigation %s = %v, want %d", field, projection[field], expected) + } + } + if response["state"] != item.state || response["requestId"] != "navigation.capacity" || response["snapshotId"] != handle.SnapshotID { + t.Fatal("navigation response lost exact completion state or request identity") + } + }) + } +} + +func TestWorkspaceLookupHTTPEnforcesExpandedWireBudget(t *testing.T) { + const wireLimit = 16 << 20 + const boundaryBytes = 1 << 20 + boundary := strings.Repeat("x", boundaryBytes) + fixture := workspaceLookupFixture(t) + contextValue := fixture["context"].(map[string]any) + source := contextValue["projections"].(map[string]any)["requirementSources"].([]any)[1].(map[string]any) + source["nonClaims"] = []any{boundary} + resignWorkspaceSnapshot(t, contextValue) + if len(stableWorkspaceBytes(t, contextValue)) >= 8<<20 { + t.Fatal("wire expansion fixture must fit the independently admitted snapshot input cap") + } + handle, capability := startWorkspaceTestServer(t, fixture, false) + // Fifteen 1 MiB source notices fit, but sixteen plus metadata cannot fit. + // The second request uses the actual first-page cardinality, not its limit. + offset := 0 + for page := 0; page < 2; page++ { + query := map[string]any{"nodeId": "spec.child", "maxRecords": json.Number("128"), "offset": json.Number(fmt.Sprint(offset))} + request, err := http.NewRequest(http.MethodPost, handle.URL+"api/v1/requirements", bytes.NewReader(stableWorkspaceBytes(t, map[string]any{"requestId": "lookup.capacity", "snapshotId": handle.SnapshotID, "query": query}))) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Origin", strings.TrimSuffix(handle.URL, "/")) + request.Header.Set("X-Proofkit-Browser-Capability", capability) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, wireLimit+1)) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil { + t.Fatalf("read expanded HTTP response: %v; close: %v", readErr, closeErr) + } + if response.StatusCode != http.StatusOK || len(body) > wireLimit || len(body) <= 15*boundaryBytes { + t.Fatalf("expanded HTTP response status=%d bytes=%d, want 200 with 15 MiB < bytes <= 16 MiB", response.StatusCode, len(body)) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(body), wireLimit) + if err != nil { + t.Fatal(err) + } + record := decoded.(map[string]any) + projection := record["projection"].(map[string]any) + rows := projection["requirements"].([]any) + ids := make([]string, 15) + for index := range ids { + ids[index] = fmt.Sprintf("REQ-B-%03d", offset+index) + } + assertWorkspaceRowIDs(t, rows, "requirementId", ids) + for index, raw := range rows { + row := raw.(map[string]any) + anchor := row["anchor"].(map[string]any) + if anchor["jsonPointer"] != fmt.Sprintf("/projections/requirementSources/1/requirements/%d/invariant", offset+index) || anchor["sourceDigest"] != digest.SHA256TextRef("source-b") { + t.Fatal("byte-limited HTTP page rebased an original source anchor") + } + claims := row["sourceNonClaims"].([]any) + if len(claims) != 1 || claims[0] != boundary { + t.Fatal("byte-limited HTTP page truncated its admitted source notice") + } + } + for field, expected := range map[string]int{"availableRequirementCount": 132, "matchingRequirementCount": 131, "selectedRequirementCount": 15, "filteredOutRequirementCount": 1, "pageOmittedRequirementCount": 116, "omittedRequirementCount": 117} { + if projection[field] != json.Number(fmt.Sprint(expected)) { + t.Fatalf("byte-limited HTTP %s = %v, want %d", field, projection[field], expected) + } + } + if record["state"] != "partial_with_omissions" || record["requestId"] != "lookup.capacity" || record["snapshotId"] != handle.SnapshotID { + t.Fatal("byte-limited HTTP page lost completion state or request identity") + } + offset += len(rows) + } +} diff --git a/internal/command/requirementbrowser/workspace_lookup.go b/internal/command/requirementbrowser/workspace_lookup.go new file mode 100644 index 0000000..b447919 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_lookup.go @@ -0,0 +1,236 @@ +package requirementbrowser + +import ( + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +const ( + maxWorkspaceSearchBytes = 1024 + maxWorkspaceSearchRunes = 256 +) + +type workspaceRequirement struct { + Anchor workspaceAnchor + Requirement requirementsourceadmission.Requirement + SearchFields [3]string + SourceID string + SourceNonClaims []string +} + +type workspaceLookupIndex struct { + Rows []workspaceRequirement + Nodes map[string]requirementspectree.Node + Children map[string][]string + SourcesByNode map[string][]string + Owners map[string]struct{} + LifecycleStates map[string]struct{} + RootNodeID string +} + +type workspaceLookupQuery struct { + Page projectionQuery + SearchText string + NodeID string + OwnerID string + LifecycleState string +} + +func buildWorkspaceLookupIndex(snapshot requirementcontext.Snapshot) (workspaceLookupIndex, map[string]workspaceAnchor) { + index := workspaceLookupIndex{ + Nodes: map[string]requirementspectree.Node{}, Children: map[string][]string{}, + SourcesByNode: map[string][]string{}, Owners: map[string]struct{}{}, + LifecycleStates: map[string]struct{}{}, RootNodeID: snapshot.Tree.RootNodeID, + } + digests := map[string]string{} + for _, source := range snapshot.Sources { + if source.Kind == "requirement_source" { + digests[source.SourceRef] = source.CurrentDigest + } + } + anchors := map[string]workspaceAnchor{} + // The snapshot owner orders both typed sources and their wire projection. + for sourceIndex, source := range snapshot.RequirementSources { + for requirementIndex, requirement := range source.Requirements { + anchor := workspaceAnchor{ + AnchorID: "requirement:" + requirement.RequirementID + ":invariant", + JSONPointer: fmt.Sprintf("/projections/requirementSources/%d/requirements/%d/invariant", sourceIndex, requirementIndex), + RequirementID: requirement.RequirementID, SourceDigest: digests[source.SourceID], Text: requirement.Invariant, + } + anchors[anchor.AnchorID] = anchor + index.Rows = append(index.Rows, workspaceRequirement{ + Anchor: anchor, Requirement: requirement, SourceID: source.SourceID, SourceNonClaims: source.NonClaims, + SearchFields: [3]string{strings.ToLower(requirement.RequirementID), strings.ToLower(requirement.OwnerID), strings.ToLower(requirement.Invariant)}, + }) + index.Owners[requirement.OwnerID] = struct{}{} + index.LifecycleStates[requirement.Lifecycle.State] = struct{}{} + } + } + sort.Slice(index.Rows, func(a, b int) bool { + return index.Rows[a].Requirement.RequirementID < index.Rows[b].Requirement.RequirementID + }) + for _, node := range snapshot.Tree.Nodes { + index.Nodes[node.NodeID] = node + for _, ref := range node.SourceRefs { + if ref.SourceRefKind == "source_id" && ref.SourceRole == "requirements" { + index.SourcesByNode[node.NodeID] = append(index.SourcesByNode[node.NodeID], ref.SourceID) + } + } + } + for _, edge := range snapshot.Tree.Edges { + index.Children[edge.ParentNodeID] = append(index.Children[edge.ParentNodeID], edge.ChildNodeID) + } + for _, children := range index.Children { + sort.Slice(children, func(a, b int) bool { + left, right := index.Nodes[children[a]], index.Nodes[children[b]] + if left.DisplayOrder != right.DisplayOrder { + return left.DisplayOrder < right.DisplayOrder + } + return left.NodeID < right.NodeID + }) + } + return index, anchors +} + +func admitWorkspaceLookupQuery(raw any, index workspaceLookupIndex) (workspaceLookupQuery, error) { + if raw == nil { + page, _ := admitProjectionQuery(nil) + return workspaceLookupQuery{Page: page}, nil + } + record, ok := raw.(map[string]any) + if !ok { + return workspaceLookupQuery{}, fmt.Errorf("browser lookup query must be an object") + } + if err := admit.KnownKeys(record, []string{"edgeOffset", "lifecycleState", "maxEdges", "maxRecords", "nodeId", "offset", "ownerId", "searchText"}, "browser lookup query"); err != nil { + return workspaceLookupQuery{}, err + } + numeric := map[string]any{} + for _, key := range []string{"edgeOffset", "maxEdges", "maxRecords", "offset"} { + if value, present := record[key]; present { + numeric[key] = value + } + } + page, err := admitProjectionQuery(numeric) + if err != nil { + return workspaceLookupQuery{}, err + } + query := workspaceLookupQuery{Page: page} + if rawSearch, present := record["searchText"]; present { + text, ok := rawSearch.(string) + if !ok || len(text) > maxWorkspaceSearchBytes || !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxWorkspaceSearchRunes { + return workspaceLookupQuery{}, fmt.Errorf("browser search text exceeds its type or size contract") + } + text = strings.TrimSpace(text) + if text != "" { + if _, err := admit.NonEmptyText(text, "browser search text"); err != nil { + return workspaceLookupQuery{}, err + } + } + query.SearchText = strings.ToLower(text) + } + for _, field := range []struct { + key string + target *string + }{{"nodeId", &query.NodeID}, {"ownerId", &query.OwnerID}} { + if raw, present := record[field.key]; present { + *field.target, err = admit.RuleID(raw, "browser lookup "+field.key) + if err != nil { + return workspaceLookupQuery{}, err + } + } + } + if query.NodeID != "" { + if _, ok := index.Nodes[query.NodeID]; !ok { + return workspaceLookupQuery{}, fmt.Errorf("browser lookup node is unknown") + } + } + if query.OwnerID != "" { + if _, ok := index.Owners[query.OwnerID]; !ok { + return workspaceLookupQuery{}, fmt.Errorf("browser lookup owner is unknown") + } + } + if raw, present := record["lifecycleState"]; present { + query.LifecycleState, err = requirementsourceadmission.AdmitLifecycleState(raw, "browser lookup lifecycleState") + if err != nil { + return workspaceLookupQuery{}, err + } + } + return query, nil +} + +func (index workspaceLookupIndex) matchingRequirements(query workspaceLookupQuery) []int { + var sources map[string]struct{} + if query.NodeID != "" { + sources = map[string]struct{}{} + pending := []string{query.NodeID} + for len(pending) > 0 { + nodeID := pending[len(pending)-1] + pending = pending[:len(pending)-1] + for _, sourceID := range index.SourcesByNode[nodeID] { + sources[sourceID] = struct{}{} + } + pending = append(pending, index.Children[nodeID]...) + } + } + matches := []int{} + for position, row := range index.Rows { + if sources != nil { + if _, ok := sources[row.SourceID]; !ok { + continue + } + } + if query.OwnerID != "" && row.Requirement.OwnerID != query.OwnerID || query.LifecycleState != "" && row.Requirement.Lifecycle.State != query.LifecycleState { + continue + } + if query.SearchText != "" && !strings.Contains(row.SearchFields[0], query.SearchText) && !strings.Contains(row.SearchFields[1], query.SearchText) && !strings.Contains(row.SearchFields[2], query.SearchText) { + continue + } + matches = append(matches, position) + } + return matches +} + +func (row workspaceRequirement) value() map[string]any { + return map[string]any{ + "anchor": anchorValue(row.Anchor), "claimLevel": row.Requirement.ClaimLevel, + "invariant": row.Requirement.Invariant, "lifecycleState": row.Requirement.Lifecycle.State, + "nonClaims": admit.StringSliceToAny(row.Requirement.NonClaims), "ownerId": row.Requirement.OwnerID, + "requirementId": row.Requirement.RequirementID, "sourceNonClaims": admit.StringSliceToAny(row.SourceNonClaims), + } +} + +func workspaceLookupPage(index workspaceLookupIndex, query workspaceLookupQuery) workspacePage { + matches := index.matchingRequirements(query) + return workspacePage{ + Count: len(matches), Offset: query.Page.Offset, Limit: query.Page.MaxRecords, RowsKey: "requirements", + Row: func(position int) map[string]any { return index.Rows[matches[position]].value() }, + Projection: func(rows []any) (map[string]any, string) { + state := "complete" + if len(rows) != len(index.Rows) { + state = "partial_with_omissions" + } + return map[string]any{ + "authority": "lookup_fragment_only", "projectionKind": "proofkit.requirement-browser-requirement-fragment", + "availableRequirementCount": len(index.Rows), "matchingRequirementCount": len(matches), "selectedRequirementCount": len(rows), + "filteredOutRequirementCount": len(index.Rows) - len(matches), "pageOmittedRequirementCount": len(matches) - len(rows), + "omittedRequirementCount": len(index.Rows) - len(rows), "requirements": rows, + }, state + }, + } +} + +func workspaceSortedSet(values map[string]struct{}) []any { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return admit.StringSliceToAny(keys) +} diff --git a/internal/command/requirementbrowser/workspace_lookup_admission_test.go b/internal/command/requirementbrowser/workspace_lookup_admission_test.go new file mode 100644 index 0000000..ddc6c1f --- /dev/null +++ b/internal/command/requirementbrowser/workspace_lookup_admission_test.go @@ -0,0 +1,77 @@ +package requirementbrowser + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestWorkspaceLookupQueryAdmitsExactTextBoundsBeforeIndexUse(t *testing.T) { + for _, text := range []string{"", " \t\n", strings.Repeat("x", 256), strings.Repeat("\U0001f9ed", 256)} { + if _, err := admitWorkspaceLookupQuery(map[string]any{"searchText": text}, workspaceLookupIndex{}); err != nil { + t.Fatalf("valid boundary text rejected: %v", err) + } + } + for _, raw := range []any{nil, true, json.Number("1"), strings.Repeat("x", 257), strings.Repeat("\U0001f9ed", 257), string([]byte{0xff})} { + if _, err := admitWorkspaceLookupQuery(map[string]any{"searchText": raw}, workspaceLookupIndex{}); err == nil { + t.Fatal("invalid text type, encoding or bound was admitted") + } + } +} + +func TestWorkspaceLookupAdmissionRejectsWithoutDisclosureOrSessionMutation(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceLookupFixture(t), false) + sentinel := "api_key=" + strings.Repeat("a", 40) + for _, item := range []struct { + path string + query map[string]any + }{ + {"requirements", map[string]any{"searchText": sentinel}}, + {"requirements", map[string]any{sentinel: true}}, + {"requirements", map[string]any{"nodeId": "spec.unknown"}}, + {"requirements", map[string]any{"nodeId": nil}}, + {"requirements", map[string]any{"ownerId": "owner.unknown"}}, + {"requirements", map[string]any{"ownerId": nil}}, + {"requirements", map[string]any{"lifecycleState": "invented"}}, + {"requirements", map[string]any{"lifecycleState": nil}}, + {"navigation", map[string]any{"parentNodeId": "spec.unknown"}}, + {"navigation", map[string]any{"parentNodeId": nil}}, + {"navigation", map[string]any{"maxRecords": json.Number("129")}}, + {"navigation", map[string]any{"maxRecords": nil}}, + {"navigation", map[string]any{"offset": json.Number("-1")}}, + } { + body := stableWorkspaceBytes(t, map[string]any{"requestId": "query.rejected", "snapshotId": handle.SnapshotID, "query": item.query}) + request, err := http.NewRequest(http.MethodPost, handle.URL+"api/v1/"+item.path, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", strings.TrimSuffix(handle.URL, "/")) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Proofkit-Browser-Capability", capability) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusBadRequest || string(output) != "request rejected\n" || bytes.Contains(output, []byte(sentinel)) { + t.Fatal("rejected query escaped its fixed nondisclosing error contract") + } + } + valid := postWorkspaceJSON(t, handle.URL+"api/v1/requirements", capability, map[string]any{"requestId": "query.valid", "snapshotId": handle.SnapshotID, "query": map[string]any{"searchText": "REQ-C"}}) + assertWorkspaceRowIDs(t, valid["projection"].(map[string]any)["requirements"], "requirementId", []string{"REQ-C"}) + manifest := getWorkspaceJSON(t, handle.URL+"api/v1/manifest", capability) + facets := manifest["lookupFacets"].(map[string]any) + if got := facets["ownerIds"].([]any); len(got) != 3 || got[0] != "owner.a" || got[1] != "owner.b" || got[2] != "owner.c" { + t.Fatal("owner facets were derived from the current page rather than the full cohort") + } + if got := facets["lifecycleStates"].([]any); len(got) != 2 || got[0] != "active" || got[1] != "superseded" { + t.Fatal("lifecycle facets differ from source-owned states in the admitted cohort") + } +} diff --git a/internal/command/requirementbrowser/workspace_lookup_fixture_test.go b/internal/command/requirementbrowser/workspace_lookup_fixture_test.go new file mode 100644 index 0000000..3f517f9 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_lookup_fixture_test.go @@ -0,0 +1,75 @@ +package requirementbrowser + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func workspaceLookupFixture(t *testing.T) map[string]any { + t.Helper() + fixture := workspaceFixture(t) + contextValue := fixture["context"].(map[string]any) + projections := contextValue["projections"].(map[string]any) + template := projections["requirementSources"].([]any)[0].(map[string]any) + requirementTemplate := template["requirements"].([]any)[0].(map[string]any) + makeRequirement := func(id, owner, invariant string) map[string]any { + record := cloneWorkspaceRecord(t, requirementTemplate) + record["requirementId"], record["ownerId"], record["invariant"] = id, owner, invariant + record["updatePolicy"].(map[string]any)["reviewOwnerId"] = owner + return record + } + makeSource := func(name string, requirements []any) map[string]any { + source := cloneWorkspaceRecord(t, template) + source["sourceId"] = "consumer." + name + source["specPackagePath"] = "docs/specs/" + name + source["overviewPath"] = "docs/specs/" + name + "/overview.md" + source["requirementsPath"] = "docs/specs/" + name + "/requirements.v1.json" + source["requirements"] = requirements + return source + } + rows := make([]any, 130) + for index := range rows { + rows[index] = makeRequirement(fmt.Sprintf("REQ-B-%03d", index), "owner.b", fmt.Sprintf("Capability %03d remains explicit.", index)) + } + rows[0].(map[string]any)["claimLevel"] = "advisory" + rows[0].(map[string]any)["lifecycle"] = map[string]any{"state": "superseded", "replacementRequirementIds": []any{"REQ-B-001"}, "evidenceRefs": []any{"consumer.migration"}} + rows[1].(map[string]any)["ownerId"] = "owner.c" + rows[1].(map[string]any)["updatePolicy"].(map[string]any)["reviewOwnerId"] = "owner.c" + rows[129].(map[string]any)["invariant"] = "State \U0001f9ed e\u0301 keeps source identity." + projections["requirementSources"] = []any{ + makeSource("a", []any{makeRequirement("REQ-A", "owner.a", "The root contract remains explicit.")}), + makeSource("b", rows), + makeSource("c", []any{makeRequirement("REQ-C", "owner.c", "The nested contract remains explicit.")}), + } + ref := func(id, role, source string) map[string]any { + return map[string]any{"sourceRefId": id, "sourceRefKind": "source_id", "sourceRole": role, "sourceId": source} + } + pathRef := func(id, role string) map[string]any { + return map[string]any{"sourceRefId": id, "sourceRefKind": "path_digest", "sourceRole": role, "sourcePath": "docs/auxiliary.md", "digestAlgorithm": "sha256", "recordedSourceDigest": digest.SHA256TextRef("auxiliary"), "currentSourceDigest": digest.SHA256TextRef("auxiliary")} + } + node := func(id, label, kind string, order int, refs []any) map[string]any { + return map[string]any{"nodeId": id, "label": label, "nodeKind": kind, "displayOrder": json.Number(fmt.Sprint(order)), "callerAnnotations": []any{}, "sourceRefs": refs} + } + tree := projections["specTree"].(map[string]any) + tree["nodes"] = []any{ + node("spec.root", "Workspace root", "meta_spec", 50, []any{ref("root.requirements", "requirements", "consumer.a"), pathRef("root.path-a", "requirements"), pathRef("root.path-b", "overview")}), + node("spec.child", "Child contracts", "module_spec", 10, []any{ref("child.requirements", "requirements", "consumer.b"), ref("child.overview", "overview", "consumer.a")}), + node("spec.grandchild", "Nested contracts", "submodule_spec", 90, []any{ref("grandchild.requirements", "requirements", "consumer.c"), ref("grandchild.overview", "overview", "consumer.b")}), + } + tree["edges"] = []any{map[string]any{"parentNodeId": "spec.root", "childNodeId": "spec.child"}, map[string]any{"parentNodeId": "spec.child", "childNodeId": "spec.grandchild"}} + tree["overlays"] = []any{ + map[string]any{"overlayId": "overlay.path-a", "overlayKind": "source", "label": "Auxiliary requirements", "refKind": "source_ref", "refId": "root.path-a", "targetNodeId": "spec.root", "callerAnnotations": []any{}}, + map[string]any{"overlayId": "overlay.path-b", "overlayKind": "source", "label": "Auxiliary overview", "refKind": "source_ref", "refId": "root.path-b", "targetNodeId": "spec.root", "callerAnnotations": []any{}}, + } + contextValue["sources"] = []any{ + map[string]any{"currentDigest": digest.SHA256TextRef("source-a"), "kind": "requirement_source", "nodeId": "spec.root", "path": "docs/specs/a/requirements.v1.json", "sourceRef": "consumer.a", "sourceRole": "requirements"}, + map[string]any{"currentDigest": digest.SHA256TextRef("source-b"), "kind": "requirement_source", "nodeId": "spec.child", "path": "docs/specs/b/requirements.v1.json", "sourceRef": "consumer.b", "sourceRole": "requirements"}, + map[string]any{"currentDigest": digest.SHA256TextRef("source-c"), "kind": "requirement_source", "nodeId": "spec.grandchild", "path": "docs/specs/c/requirements.v1.json", "sourceRef": "consumer.c", "sourceRole": "requirements"}, + map[string]any{"currentDigest": digest.SHA256TextRef("tree"), "kind": "spec_tree", "path": "proofkit/spec-tree.json", "sourceRef": "spec_tree:consumer.spec-tree"}, + } + resignWorkspaceSnapshot(t, contextValue) + return fixture +} diff --git a/internal/command/requirementbrowser/workspace_lookup_test.go b/internal/command/requirementbrowser/workspace_lookup_test.go new file mode 100644 index 0000000..8c9bbd0 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_lookup_test.go @@ -0,0 +1,129 @@ +package requirementbrowser + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +func TestWorkspaceLookupFiltersTheWholeCohortBeforePaging(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceLookupFixture(t), false) + for _, item := range []struct { + name string + query map[string]any + ids []string + matching int + }{ + {"nested role isolation", map[string]any{"nodeId": "spec.grandchild"}, []string{"REQ-C"}, 1}, + {"known empty intersection", map[string]any{"nodeId": "spec.grandchild", "ownerId": "owner.b"}, []string{}, 0}, + {"full cohort search", map[string]any{"searchText": "keeps source identity"}, []string{"REQ-B-129"}, 1}, + {"unicode literal", map[string]any{"searchText": " \U0001f9ed E\u0301 "}, []string{"REQ-B-129"}, 1}, + {"no implicit unicode normalization", map[string]any{"searchText": "\u00e9"}, []string{}, 0}, + {"id simple case", map[string]any{"searchText": "req-b-128"}, []string{"REQ-B-128"}, 1}, + {"no cross field concatenation", map[string]any{"searchText": "REQ-B-129 owner.b"}, []string{}, 0}, + {"lifecycle without replacement expansion", map[string]any{"lifecycleState": "superseded", "ownerId": "owner.b"}, []string{"REQ-B-000"}, 1}, + {"intersection before offset", map[string]any{"nodeId": "spec.child", "ownerId": "owner.b", "lifecycleState": "active", "offset": json.Number("127"), "maxRecords": json.Number("64")}, []string{"REQ-B-129"}, 128}, + {"descendants but not ancestors", map[string]any{"nodeId": "spec.child", "offset": json.Number("130")}, []string{"REQ-C"}, 131}, + } { + t.Run(item.name, func(t *testing.T) { + response := postWorkspaceJSON(t, handle.URL+"api/v1/requirements", capability, map[string]any{"requestId": "lookup.test", "snapshotId": handle.SnapshotID, "query": item.query}) + projection := response["projection"].(map[string]any) + assertWorkspaceRowIDs(t, projection["requirements"], "requirementId", item.ids) + for key, want := range map[string]int{"availableRequirementCount": 132, "matchingRequirementCount": item.matching, "selectedRequirementCount": len(item.ids), "filteredOutRequirementCount": 132 - item.matching, "pageOmittedRequirementCount": item.matching - len(item.ids), "omittedRequirementCount": 132 - len(item.ids)} { + if projection[key] != json.Number(fmt.Sprint(want)) { + t.Fatalf("%s = %v, want %d", key, projection[key], want) + } + } + if response["requestId"] != "lookup.test" || response["snapshotId"] != handle.SnapshotID || response["state"] != "partial_with_omissions" { + t.Fatal("lookup lost request identity or existing omission semantics") + } + }) + } +} + +func TestWorkspaceLookupPreservesOriginalAnchorAndDistinctHandoffClosure(t *testing.T) { + workspace, _, err := buildWorkspace(workspaceLookupFixture(t)) + if err != nil { + t.Fatal(err) + } + handle, capability := startWorkspaceTestServer(t, workspaceLookupFixture(t), false) + response := postWorkspaceJSON(t, handle.URL+"api/v1/requirements", capability, map[string]any{"requestId": "lookup.anchor", "snapshotId": handle.SnapshotID, "query": map[string]any{"lifecycleState": "superseded"}}) + rows := response["projection"].(map[string]any)["requirements"].([]any) + assertWorkspaceRowIDs(t, rows, "requirementId", []string{"REQ-B-000"}) + anchor := rows[0].(map[string]any)["anchor"].(map[string]any) + if anchor["jsonPointer"] != "/projections/requirementSources/1/requirements/0/invariant" || anchor["sourceDigest"] != digest.SHA256TextRef("source-b") { + t.Fatal("lookup rebased the original source anchor") + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/handoff", strings.NewReader(`{"annotations":[{"anchorId":"requirement:REQ-B-000:invariant","exactQuote":"Capability","startCodePoint":0,"endCodePoint":10,"question":"Is replacement required?"}]}`)) + packet, err := buildHandoffPacket(request, workspace) + if err != nil { + t.Fatal(err) + } + contextValue := packet["context"].(map[string]any) + projections := contextValue["projections"].(map[string]any) + sources := projections["requirementSources"].([]any) + if len(sources) != 1 || sources[0].(map[string]any)["sourceId"] != "consumer.b" { + t.Fatal("handoff source closure is not the selected source") + } + assertWorkspaceRowIDs(t, sources[0].(map[string]any)["requirements"], "requirementId", []string{"REQ-B-000", "REQ-B-001"}) + tree := projections["specTree"].(map[string]any) + if len(tree["overlays"].([]any)) != 2 { + t.Fatal("handoff lost the two distinct path-reference overlays") + } + for _, raw := range tree["nodes"].([]any) { + node := raw.(map[string]any) + if node["nodeId"] != "spec.root" { + continue + } + assertWorkspaceRowIDs(t, node["sourceRefs"], "sourceRefId", []string{"root.path-a", "root.path-b"}) + } +} + +func TestWorkspaceNavigationPagesAdmittedChildrenOnly(t *testing.T) { + handle, capability := startWorkspaceTestServer(t, workspaceLookupFixture(t), false) + for _, item := range []struct { + query map[string]any + ids []string + }{ + {map[string]any{}, []string{"spec.root"}}, + {map[string]any{"parentNodeId": "spec.root"}, []string{"spec.child"}}, + {map[string]any{"parentNodeId": "spec.child"}, []string{"spec.grandchild"}}, + {map[string]any{"parentNodeId": "spec.grandchild"}, []string{}}, + {map[string]any{"parentNodeId": "spec.root", "offset": json.Number("1")}, []string{}}, + } { + response := postWorkspaceJSON(t, handle.URL+"api/v1/navigation", capability, map[string]any{"requestId": "navigation.test", "snapshotId": handle.SnapshotID, "query": item.query}) + projection := response["projection"].(map[string]any) + assertWorkspaceRowIDs(t, projection["nodes"], "nodeId", item.ids) + if projection["authority"] != "lookup_fragment_only" || response["snapshotId"] != handle.SnapshotID || response["requestId"] != "navigation.test" { + t.Fatal("navigation promoted authority or lost session identity") + } + if parent, ok := item.query["parentNodeId"]; ok { + if projection["parent"].(map[string]any)["nodeId"] != parent { + t.Fatal("navigation page lost its parent context") + } + } else if projection["parent"] != nil { + t.Fatal("root navigation invented a parent") + } + } +} + +func assertWorkspaceRowIDs(t *testing.T, raw any, key string, expected []string) { + t.Helper() + rows, ok := raw.([]any) + if !ok { + t.Fatalf("expected row array, got %T", raw) + } + actual := make([]string, 0, len(rows)) + for _, raw := range rows { + actual = append(actual, raw.(map[string]any)[key].(string)) + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("%s order = %v, want %v", key, actual, expected) + } +} diff --git a/internal/command/requirementbrowser/workspace_navigation.go b/internal/command/requirementbrowser/workspace_navigation.go new file mode 100644 index 0000000..174c49d --- /dev/null +++ b/internal/command/requirementbrowser/workspace_navigation.go @@ -0,0 +1,78 @@ +package requirementbrowser + +import ( + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +type workspaceNavigationQuery struct { + ParentNodeID string + Offset int + MaxRecords int +} + +func admitWorkspaceNavigationQuery(raw any, index workspaceLookupIndex) (workspaceNavigationQuery, error) { + query := workspaceNavigationQuery{MaxRecords: 64} + if raw == nil { + return query, nil + } + record, ok := raw.(map[string]any) + if !ok { + return workspaceNavigationQuery{}, fmt.Errorf("browser navigation query must be an object") + } + if err := admit.KnownKeys(record, []string{"maxRecords", "offset", "parentNodeId"}, "browser navigation query"); err != nil { + return workspaceNavigationQuery{}, err + } + var err error + if raw, present := record["parentNodeId"]; present { + query.ParentNodeID, err = admit.RuleID(raw, "browser navigation parentNodeId") + if err != nil { + return workspaceNavigationQuery{}, err + } + if _, ok := index.Nodes[query.ParentNodeID]; !ok { + return workspaceNavigationQuery{}, fmt.Errorf("browser navigation parent is unknown") + } + } + if raw, present := record["offset"]; present { + query.Offset, err = nonNegativeJSONInteger(raw, "browser navigation offset") + if err != nil { + return workspaceNavigationQuery{}, err + } + } + if raw, present := record["maxRecords"]; present { + query.MaxRecords, err = positiveJSONInteger(raw, "browser navigation maxRecords") + if err != nil || query.MaxRecords > 128 { + return workspaceNavigationQuery{}, fmt.Errorf("browser navigation maxRecords must be between 1 and 128") + } + } + return query, nil +} + +func workspaceNavigationPage(index workspaceLookupIndex, query workspaceNavigationQuery) workspacePage { + nodes := []string{index.RootNodeID} + var parent any + if query.ParentNodeID != "" { + nodes = index.Children[query.ParentNodeID] + parent = index.navigationNodeValue(query.ParentNodeID) + } + return workspacePage{ + Count: len(nodes), Offset: query.Offset, Limit: query.MaxRecords, RowsKey: "nodes", + Row: func(position int) map[string]any { return index.navigationNodeValue(nodes[position]) }, + Projection: func(rows []any) (map[string]any, string) { + state := "complete" + if len(rows) != len(nodes) { + state = "partial_with_omissions" + } + return map[string]any{ + "authority": "lookup_fragment_only", "projectionKind": "proofkit.requirement-browser-navigation-fragment", + "parent": parent, "nodes": rows, "availableNodeCount": len(nodes), "selectedNodeCount": len(rows), "omittedNodeCount": len(nodes) - len(rows), + }, state + }, + } +} + +func (index workspaceLookupIndex) navigationNodeValue(nodeID string) map[string]any { + node := index.Nodes[nodeID] + return map[string]any{"nodeId": node.NodeID, "nodeKind": node.NodeKind, "label": node.Label, "childCount": len(index.Children[nodeID])} +} diff --git a/internal/command/requirementbrowser/workspace_page.go b/internal/command/requirementbrowser/workspace_page.go new file mode 100644 index 0000000..4f98b29 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_page.go @@ -0,0 +1,69 @@ +package requirementbrowser + +import ( + "encoding/json" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +const maxWorkspaceLookupResponseBytes = 16 << 20 + +// Both private lookup routes share the encoded-page budget, not row semantics. +type workspacePage struct { + Count int + Offset int + Limit int + RowsKey string + Row func(int) map[string]any + Projection func([]any) (map[string]any, string) +} + +func (page workspacePage) encode(requestID, snapshotID string, byteLimit int) ([]byte, error) { + rows := []any{} + rowBytes := 0 + start := min(page.Offset, page.Count) + end := start + min(page.Limit, page.Count-start) + envelope := func(selected []any) map[string]any { + projection, state := page.Projection(selected) + return map[string]any{"projection": projection, "requestId": requestID, "schemaVersion": json.Number("2"), "snapshotId": snapshotID, "state": state} + } + empty, err := stablejson.MarshalLayout(envelope(rows), stablejson.LayoutCompact) + if err != nil { + return nil, err + } + if len(empty) > byteLimit { + return nil, fmt.Errorf("browser lookup metadata exceeds response capacity") + } + for position := start; position < end; position++ { + row := page.Row(position) + encodedRow, err := stablejson.MarshalLayout(row, stablejson.LayoutCompact) + if err != nil { + return nil, err + } + candidate := append(rows, row) + // Count fields change, but previously encoded row bytes are not revisited. + metadata := envelope(candidate) + metadata["projection"].(map[string]any)[page.RowsKey] = []any{} + header, err := stablejson.MarshalLayout(metadata, stablejson.LayoutCompact) + if err != nil { + return nil, err + } + nextRowBytes := rowBytes + len(encodedRow) - 1 + if len(header)+len(candidate)-1+nextRowBytes > byteLimit { + if len(rows) == 0 { + return nil, fmt.Errorf("browser lookup record exceeds response capacity") + } + break + } + rows, rowBytes = candidate, nextRowBytes + } + body, err := stablejson.MarshalLayout(envelope(rows), stablejson.LayoutCompact) + if err != nil { + return nil, err + } + if len(body) > byteLimit { + return nil, fmt.Errorf("browser lookup metadata exceeds response capacity") + } + return body, nil +} diff --git a/internal/command/requirementbrowser/workspace_page_test.go b/internal/command/requirementbrowser/workspace_page_test.go new file mode 100644 index 0000000..a27c467 --- /dev/null +++ b/internal/command/requirementbrowser/workspace_page_test.go @@ -0,0 +1,76 @@ +package requirementbrowser + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +func TestWorkspacePageByteLimitPreservesExactPrefixAndProbeBound(t *testing.T) { + visits := []int{} + page := workspacePage{ + Count: 5, Limit: 5, RowsKey: "rows", + Row: func(index int) map[string]any { + visits = append(visits, index) + return map[string]any{"label": strings.Repeat(string(rune('A'+index)), 64)} + }, + Projection: func(rows []any) (map[string]any, string) { + return map[string]any{"available": 5, "rows": rows, "selected": len(rows)}, "partial_with_omissions" + }, + } + expected := `{"projection":{"available":5,"rows":[{"label":"` + strings.Repeat("A", 64) + `"},{"label":"` + strings.Repeat("B", 64) + `"}],"selected":2},"requestId":"page.test","schemaVersion":2,"snapshotId":"snapshot.test","state":"partial_with_omissions"}` + "\n" + body, err := page.encode("page.test", "snapshot.test", len(expected)) + if err != nil { + t.Fatal(err) + } + if string(body) != expected { + t.Fatal("exact byte-limit page differs from independently authored wire body") + } + if len(visits) != 3 || visits[0] != 0 || visits[1] != 1 || visits[2] != 2 { + t.Fatalf("row work = %v, want retained rows plus one overflow probe", visits) + } + visits = nil + one, err := page.encode("page.test", "snapshot.test", len(expected)-1) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(one), int64(len(one))) + if err != nil { + t.Fatal(err) + } + projection := value.(map[string]any)["projection"].(map[string]any) + if projection["selected"] != json.Number("1") || len(visits) != 2 { + t.Fatal("one byte below the two-row bound did not retain one exact row") + } + visits = nil + if _, err := page.encode("page.test", "snapshot.test", 1); err == nil || !strings.Contains(err.Error(), "metadata") || len(visits) != 0 { + t.Fatal("metadata overflow did not dominate row materialization") + } + visits = nil + if _, err := page.encode("page.test", "snapshot.test", 200); err == nil || !strings.Contains(err.Error(), "record") || len(visits) != 1 { + t.Fatal("unfit first record did not fail without materializing later rows") + } +} + +func TestWorkspacePageOffsetDoesNotMaterializeExcludedRows(t *testing.T) { + visits := []int{} + page := workspacePage{ + Count: 100, Offset: 98, Limit: 10, RowsKey: "rows", + Row: func(index int) map[string]any { + visits = append(visits, index) + return map[string]any{"position": index} + }, + Projection: func(rows []any) (map[string]any, string) { + return map[string]any{"rows": rows}, "partial_with_omissions" + }, + } + if _, err := page.encode("page.test", "snapshot.test", 4096); err != nil { + t.Fatal(err) + } + if len(visits) != 2 || visits[0] != 98 || visits[1] != 99 { + t.Fatalf("offset window materialized excluded rows: %v", visits) + } +} diff --git a/internal/command/requirementbrowser/workspace_test.go b/internal/command/requirementbrowser/workspace_test.go index ad07e56..a17a950 100644 --- a/internal/command/requirementbrowser/workspace_test.go +++ b/internal/command/requirementbrowser/workspace_test.go @@ -512,22 +512,6 @@ func TestGraphWindowRetainsCrossPageRelationsWithEndpointClosure(t *testing.T) { } } -func TestRequirementWindowMakesEveryBoundedPageReachable(t *testing.T) { - requirements := make([]any, 257) - for index := range requirements { - requirements[index] = map[string]any{"requirementId": fmt.Sprintf("REQ-%03d", index)} - } - first, firstState := requirementWindow(requirements, projectionQuery{MaxRecords: 256}) - second, secondState := requirementWindow(requirements, projectionQuery{MaxRecords: 256, Offset: 256}) - if firstState != "partial_with_omissions" || first["selectedRequirementCount"] != 256 || secondState != "partial_with_omissions" || second["selectedRequirementCount"] != 1 { - t.Fatalf("requirement pagination is not omission-honest: first=%#v second=%#v", first, second) - } - last := second["requirements"].([]any)[0].(map[string]any)["requirementId"] - if last != "REQ-256" { - t.Fatalf("last requirement is unreachable: %v", last) - } -} - func workspaceFixture(t *testing.T) map[string]any { return workspaceFixtureWithInvariant(t, "The system preserves semantic identity. "+strings.Repeat("x", maxHandoffAnnotations+1)) } diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 626710a..9e8c5d4 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "bcbfee8a2bc84bf17a433e8cc05e34c9df4460d68b323177e579b0a8b8ff34fe" +const presetContractSourceSHA256 = "187b115fbdb47c8e88154e118a414a1f9e779c7663eec8a4a09c843a71bd61e9" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/testsupport/browserfixture/fixture.go b/internal/testsupport/browserfixture/fixture.go index 9520d50..63e264a 100644 --- a/internal/testsupport/browserfixture/fixture.go +++ b/internal/testsupport/browserfixture/fixture.go @@ -13,11 +13,19 @@ import ( const RequirementID = "REQ-CONSUMER-001" func Workspace() (map[string]any, error) { - base, err := snapshot("The system preserves the original semantic identity.") + return workspace("high") +} + +func PagingWorkspace() (map[string]any, error) { + return workspace("medium") +} + +func workspace(currentRisk string) (map[string]any, error) { + base, err := snapshot("The system preserves the original semantic identity.", "high") if err != nil { return nil, err } - current, err := snapshot("The system preserves semantic identity for retry \U0001F680.") + current, err := snapshot("The system preserves semantic identity for retry \U0001F680.", currentRisk) if err != nil { return nil, err } @@ -45,11 +53,11 @@ func Workspace() (map[string]any, error) { return map[string]any{"context": current, "diffInput": diffInput, "graphInput": graphInput, "schemaVersion": json.Number("2"), "workspaceId": "browser.fixture.workspace"}, nil } -func snapshot(invariant string) (map[string]any, error) { +func snapshot(invariant, risk string) (map[string]any, error) { tree := map[string]any{"callerAnnotations": []any{}, "edges": []any{}, "nodes": []any{map[string]any{"callerAnnotations": []any{}, "displayOrder": json.Number("1"), "label": "Fixture specification", "nodeId": "spec.root", "nodeKind": "meta_spec", "sourceRefs": []any{map[string]any{"sourceId": "browser.fixture.requirements", "sourceRefId": "spec.root.requirements", "sourceRefKind": "source_id", "sourceRole": "requirements"}}}}, "overlays": []any{}, "rootNodeId": "spec.root", "schemaVersion": json.Number("2"), "treeId": "browser.fixture.tree"} requirementSource := map[string]any{ "nonClaims": []any{"Fixture requirements do not approve merge."}, "overviewPath": "docs/specs/browser-fixture/overview.md", - "requirements": []any{map[string]any{"claimLevel": "blocking", "invariant": invariant, "lifecycle": map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": []any{}, "state": "active"}, "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, "ownerId": "browser.fixture.owner", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "requirementId": RequirementID, "riskClass": "high", "updatePolicy": map[string]any{"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "browser.fixture.owner"}}}, + "requirements": []any{map[string]any{"claimLevel": "blocking", "invariant": invariant, "lifecycle": map[string]any{"evidenceRefs": []any{}, "replacementRequirementIds": []any{}, "state": "active"}, "nonClaimRefs": []any{"NC-CONSUMER-001"}, "nonClaims": []any{"This requirement does not approve merge."}, "ownerId": "browser.fixture.owner", "proofBindingRefs": []any{"proofkit/requirement-bindings.json"}, "requirementId": RequirementID, "riskClass": risk, "updatePolicy": map[string]any{"requiresImpactDeclaration": true, "requiresProofBindingReview": true, "reviewOwnerId": "browser.fixture.owner"}}}, "requirementsPath": "docs/specs/browser-fixture/requirements.v1.json", "schemaVersion": json.Number("1"), "sourceId": "browser.fixture.requirements", "specPackagePath": "docs/specs/browser-fixture", } projections := map[string]any{"requirementSources": []any{requirementSource}, "specTree": tree} diff --git a/internal/testsupport/browserfixture/lookup.go b/internal/testsupport/browserfixture/lookup.go new file mode 100644 index 0000000..52115e8 --- /dev/null +++ b/internal/testsupport/browserfixture/lookup.go @@ -0,0 +1,119 @@ +package browserfixture + +import ( + "encoding/json" + "fmt" + "maps" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementsourceadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementspectree" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +func LookupWorkspace() (map[string]any, error) { + return lookupWorkspace(130) +} + +func CapacityWorkspace() (map[string]any, error) { + return lookupWorkspace(3709) +} + +func lookupWorkspace(siblingCount int) (map[string]any, error) { + contextValue, err := snapshot("Root scope remains independent.", "high") + if err != nil { + return nil, err + } + projections := contextValue["projections"].(map[string]any) + template := projections["requirementSources"].([]any)[0].(map[string]any) + rowTemplate := template["requirements"].([]any)[0].(map[string]any) + row := func(id, owner, invariant string) map[string]any { + value := maps.Clone(rowTemplate) + value["requirementId"], value["ownerId"], value["invariant"] = id, owner, invariant + value["updatePolicy"] = map[string]any{"reviewOwnerId": owner, "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + return value + } + rows := make([]any, 130) + for index := range rows { + rows[index] = row(fmt.Sprintf("REQ-B-%03d", index), "owner.b", fmt.Sprintf("Capability %03d remains explicit.", index)) + } + rows[0].(map[string]any)["claimLevel"] = "advisory" + rows[0].(map[string]any)["lifecycle"] = map[string]any{"state": "superseded", "replacementRequirementIds": []any{"REQ-B-001"}, "evidenceRefs": []any{"consumer.migration"}} + rows[1] = row("REQ-B-001", "owner.c", "Capability 001 remains explicit.") + rows[129] = row("REQ-B-129", "owner.b", "State \U0001f9ed e\u0301 keeps source identity.") + inputs := []struct { + id, node string + rows []any + }{ + {"a", "spec.root", []any{row("REQ-A", "owner.a", "Root scope remains independent.")}}, + {"b", "spec.child", rows}, + {"c", "spec.grandchild", []any{row("REQ-C", "owner.c", "Nested scope remains independent.")}}, + } + sources, identitySources, requirementSources := []any{}, []any{}, []any{} + for _, input := range inputs { + source := maps.Clone(template) + source["sourceId"], source["requirements"] = "consumer."+input.id, input.rows + source["specPackagePath"] = "docs/specs/" + input.id + source["overviewPath"], source["requirementsPath"] = "docs/specs/"+input.id+"/overview.md", "docs/specs/"+input.id+"/requirements.v1.json" + admitted, err := requirementsourceadmission.Evaluate(source) + if err != nil || admitted.ExitCode != 0 { + return nil, fmt.Errorf("lookup fixture requirement source is invalid") + } + requirementSources = append(requirementSources, requirementsourceadmission.SourceValue(admitted.Source)) + record := map[string]any{"currentDigest": digest.SHA256TextRef("source-" + input.id), "kind": "requirement_source", "nodeId": input.node, "path": source["requirementsPath"], "sourceRef": source["sourceId"], "sourceRole": "requirements"} + sources = append(sources, record) + identity := maps.Clone(record) + identity["expectedDigest"] = "" + identitySources = append(identitySources, identity) + } + projections["requirementSources"] = requirementSources + ref := func(id, role, source string) map[string]any { + return map[string]any{"sourceRefId": id, "sourceRefKind": "source_id", "sourceRole": role, "sourceId": source} + } + node := func(id, label, kind string, order int, refs []any) map[string]any { + return map[string]any{"nodeId": id, "label": label, "nodeKind": kind, "displayOrder": json.Number(fmt.Sprint(order)), "callerAnnotations": []any{}, "sourceRefs": refs} + } + tree := projections["specTree"].(map[string]any) + nodes := []any{ + node("spec.root", "Workspace root", "meta_spec", 50, []any{ref("root.requirements", "requirements", "consumer.a")}), + node("spec.child", "Child contracts", "module_spec", 10, []any{ref("child.requirements", "requirements", "consumer.b"), ref("child.overview", "overview", "consumer.a")}), + node("spec.grandchild", "Nested contracts", "submodule_spec", 90, []any{ref("grandchild.requirements", "requirements", "consumer.c"), ref("grandchild.overview", "overview", "consumer.b")}), + } + edges := []any{map[string]any{"parentNodeId": "spec.root", "childNodeId": "spec.child"}, map[string]any{"parentNodeId": "spec.child", "childNodeId": "spec.grandchild"}} + for index := range siblingCount { + id := fmt.Sprintf("spec.sibling.%03d", index) + nodes = append(nodes, node(id, fmt.Sprintf("Sibling %03d", index), "module_spec", 100+index, []any{ref(id+".overview", "overview", "consumer.a")})) + edges = append(edges, map[string]any{"parentNodeId": "spec.root", "childNodeId": id}) + } + parent := "spec.grandchild" + for depth := range 6 { + for index := range 64 { + id := fmt.Sprintf("spec.depth.%d.%03d", depth+1, index) + label := fmt.Sprintf("Level %d sibling %03d", depth+1, index) + if index == 0 { + label = fmt.Sprintf("Depth %d", depth+1) + } + nodes = append(nodes, node(id, label, "submodule_spec", index+1, []any{ref(id+".overview", "overview", "consumer.a")})) + edges = append(edges, map[string]any{"parentNodeId": parent, "childNodeId": id}) + } + parent = fmt.Sprintf("spec.depth.%d.000", depth+1) + } + tree["nodes"], tree["edges"] = nodes, edges + admittedTree, err := requirementspectree.Evaluate(tree) + if err != nil || admittedTree.ExitCode != 0 { + return nil, fmt.Errorf("lookup fixture specification tree is invalid") + } + projections["specTree"] = requirementspectree.TreeValue(admittedTree.Tree) + treeSource := map[string]any{"currentDigest": digest.SHA256TextRef("lookup-tree"), "kind": "spec_tree", "path": "proofkit/browser-fixture-tree.json", "sourceRef": "spec_tree:browser.fixture.tree"} + sources = append(sources, treeSource) + identityTree := maps.Clone(treeSource) + identityTree["expectedDigest"] = "" + identitySources = append(identitySources, identityTree) + contextValue["sources"] = sources + encoded, err := stablejson.Marshal(map[string]any{"catalogId": contextValue["catalogId"], "projections": projections, "sources": identitySources}) + if err != nil { + return nil, err + } + contextValue["snapshotId"] = digest.SHA256TextRef(string(encoded)) + return map[string]any{"schemaVersion": json.Number("2"), "workspaceId": "browser.fixture.workspace", "context": contextValue}, nil +} diff --git a/internal/testsupport/browserfixture/lookup_test.go b/internal/testsupport/browserfixture/lookup_test.go new file mode 100644 index 0000000..6462f2d --- /dev/null +++ b/internal/testsupport/browserfixture/lookup_test.go @@ -0,0 +1,35 @@ +package browserfixture + +import ( + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementcontext" +) + +func TestLookupWorkspacePreservesFullCohortAndHierarchy(t *testing.T) { + workspace, err := LookupWorkspace() + if err != nil { + t.Fatal(err) + } + snapshot, err := requirementcontext.AdmitSnapshot(workspace["context"]) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Tree.Nodes) != 517 || len(snapshot.Tree.Edges) != 516 || len(snapshot.RequirementSources) != 3 || len(snapshot.RequirementSources[1].Requirements) != 130 { + t.Fatal("lookup fixture lost its independent cohort or hierarchy") + } +} + +func TestCapacityWorkspaceReachesTheAdmittedNodeCeiling(t *testing.T) { + workspace, err := CapacityWorkspace() + if err != nil { + t.Fatal(err) + } + snapshot, err := requirementcontext.AdmitSnapshot(workspace["context"]) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Tree.Nodes) != 4096 || len(snapshot.Tree.Edges) != 4095 || len(snapshot.RequirementSources) != 3 || len(snapshot.RequirementSources[1].Requirements) != 130 { + t.Fatal("capacity fixture lost its maximum topology or independent source cohort") + } +} diff --git a/internal/tools/browsertestserver/main.go b/internal/tools/browsertestserver/main.go index 5be2096..1cd75ff 100644 --- a/internal/tools/browsertestserver/main.go +++ b/internal/tools/browsertestserver/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "os" "os/signal" "syscall" @@ -12,7 +13,17 @@ import ( ) func main() { - workspace, err := browserfixture.Workspace() + build := browserfixture.Workspace + if len(os.Args) == 2 && os.Args[1] == "--lookup" { + build = browserfixture.LookupWorkspace + } else if len(os.Args) == 2 && os.Args[1] == "--paging" { + build = browserfixture.PagingWorkspace + } else if len(os.Args) == 2 && os.Args[1] == "--capacity" { + build = browserfixture.CapacityWorkspace + } else if len(os.Args) != 1 { + fatal(errors.New("unsupported browser fixture selector")) + } + workspace, err := build() if err != nil { fatal(err) } diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 52adf71..a82ab1b 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -194,20 +194,15 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { assertCurrentChangeRecordNotesRejected(t, "appended duplicate change section", record, notes+"## Breaking Contract Changes\n\n- `proofkit.surplus.section`: Surplus section.\n") } -var currentBreakingChanges = []Change{ - {ChangeID: "proofkit.repository-transaction.bound-terminal-replay", Summary: "New terminal receipts use schema v2 to bind each completed transaction to its exact desired-state identity. Legacy schema-v1 receipts remain readable and recoverable, but cannot authorize acknowledgement replay; a newly reviewed plan is required. Roots containing v2 receipts are not compatible with older binaries."}, -} +var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.agent-integration.managed-lifecycle", Summary: "Add explicit integration plan, apply and recover routes for reviewed install, update and removal of the selected tool bootstrap. Preserve local edits and neighboring instructions with one native two-target transaction and a cooperative baseline."}, - {ChangeID: "proofkit.repository-transaction.desired-absence", Summary: "Support exact desired absence, guarded deletion and recovery through versioned native journals while preserving present-only v1 bytes and identities. Bind idempotent current-state replay to the pending-state and retained-result checks under one native lock."}, - {ChangeID: "proofkit.repository-transaction.recovery-observation", Summary: "Preserve operational target-observation failures through recovery and both CLI consumers instead of presenting them as observed state mismatches. Keep target and control state unchanged on pre-effect refusal, including temporary-journal publication, and retain cleanup outcomes after the final committed effect."}, + {ChangeID: "proofkit.browser.lookup", Summary: "Search and filter the complete admitted requirement cohort before bounded paging, navigate ordered specification children, and preserve original source anchors and exact omission counts without loading the full snapshot into the browser."}, + {ChangeID: "proofkit.browser.request-recovery", Summary: "Distinguish correction, denied, stale, unavailable and retryable failures. Explicit Retry preserves the failed route, snapshot and complete page query, while superseded requests cannot restore stale content or request authority."}, + {ChangeID: "proofkit.browser.responsive-shell", Summary: "Add compact Browse and Inspector panels with native modal behavior on smaller viewports, stable keyboard focus, retained question drafts, source-bound Unicode selection and lazy boundary details."}, } -var currentMigrationSteps = []string{ - "For an acknowledgement retry backed by a legacy terminal receipt, run adopt materialize plan again and review both current identities before apply; do not reuse the old transaction identity.", - "Use this or a later supporting binary for roots containing v2 journals or terminal receipts. Completing recovery does not make retained v2 receipts downgrade-compatible; do not delete private control state as a downgrade shortcut.", -} +var currentMigrationSteps = []string{} func validateCurrentChangeRecord(record Record, notes string) error { if !slices.Equal(record.BreakingChanges, currentBreakingChanges) { @@ -227,7 +222,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.11.0", + "# @research-engineering/agentic-proofkit 0.12.0", "", "## Breaking Contract Changes", "", @@ -249,8 +244,7 @@ func currentExpectedReleaseNotes() string { "", "## Migration", "", - "Migration is required:", - "", + "No consumer migration is required.", ) for _, step := range currentMigrationSteps { lines = append(lines, "- "+step) @@ -273,13 +267,14 @@ func currentExpectedReleaseNotes() string { "- TSX source parsing remains unsupported.", "- Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.", "- Desired-absence journals and newly retained terminal receipts use schema v2. Earlier binaries reject these records without effects. Present-only v1 plan/journal bytes and historical recovery remain supported; finishing recovery does not enable downgrade of retained v2 receipts.", + "- Browser lookup and navigation are presentation-only fragments, not proof coverage or an external HTTP SDK. Annotations are session-bound; browser controls do not edit specifications or execute agents.", "", "## Install", "", "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.11.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.12.0", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -291,7 +286,7 @@ func currentExpectedReleaseNotes() string { "## Rollback", "", "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", - "- Pin npm consumers to the previous admitted version 0.10.1 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.10.1`.", + "- Pin npm consumers to the previous admitted version 0.11.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.11.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index 3b77a11..f4b70cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.11.0", + "version": "0.12.0", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 263b942..9870012 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.11.0", + "version": "0.12.0", "type": "module", "license": "MIT", "sideEffects": false, @@ -36,7 +36,7 @@ "build": "go run ./internal/tools/packagebuild", "check": "npm run npm:version && npm run source-hygiene && npm run command-contract:check && npm run command-family:check && npm run text-policy && npm run mermaid:check && npm run go:check && npm run browser:check && npm run package:artifact && npm run self:receipt && npm run self:coverage && npm run release:closeout", "browser:check": "npm run browser:static-check && npm run browser:test", - "browser:static-check": "tsc -p tsconfig.browser.json && node --test scripts/browser-proof-execution.test.mjs scripts/browser-proof-inputs.test.mjs scripts/browser-selection-authority.test.mjs scripts/diagnostic-entrypoints.test.mjs scripts/stable-json.test.mjs", + "browser:static-check": "tsc -p tsconfig.browser.json && node --test scripts/browser-proof-execution.test.mjs scripts/browser-proof-inputs.test.mjs scripts/browser-selection-authority.test.mjs scripts/browser-workspace-requests.test.mjs scripts/diagnostic-entrypoints.test.mjs scripts/stable-json.test.mjs", "browser:test": "go run ./internal/tools/browserproofverify --run", "command-family:check": "go run ./internal/tools/commandfamilygen --check", "go:actionlint": "go tool actionlint", diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 1fb8b35..9bc1659 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -4939,7 +4939,7 @@ "rootDefinitionDigest": "sha256:e6af5b9edb1284ab2ff2f1a276a6b924127b99810cd9446bffbc971f638a0997", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:df4d6ca7f6f08342e48e44dad7cfbbcb25869c039814efd875e6435b4aeefa33", + "canonicalDigest": "sha256:2f6113b67ba6bbddac190b232ff5788aa48cd9dbec2a73f057a6152a2020b49e", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4976,7 +4976,7 @@ "rootDefinitionDigest": "sha256:c2e7d851c7928560d4267fe85ebc0ae61e33c4c0ef15f7f7b01deb73bb80eda7", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:df4d6ca7f6f08342e48e44dad7cfbbcb25869c039814efd875e6435b4aeefa33", + "canonicalDigest": "sha256:2f6113b67ba6bbddac190b232ff5788aa48cd9dbec2a73f057a6152a2020b49e", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 58d2599..126f02b 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -784,6 +784,22 @@ "proofState": "witness_backed", "nonClaims": ["A source-bound version edge does not authenticate registry publication, provider ingestion, consumer migration, native witness truth, rollout, or production readiness."] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["Lookup fragments do not establish source completeness outside the admitted snapshot, lifecycle-closed context, proof coverage, native execution, provider freshness, merge approval, or production readiness. Private browser HTTP routes are not a separately supported public SDK."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["Browser runtime witnesses cover the admitted Playwright Chromium, Firefox, and WebKit scenarios, not all browser preferences, assistive technologies, operating-system themes, branded Safari behavior, complete WCAG conformance, annotation persistence, agent execution, or provider delivery."] + }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", "ownerId": "proofkit.agent-workflow", @@ -5467,6 +5483,78 @@ "commandIds": ["proofkit.browser-check"], "environmentClasses": ["local-node-browser"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "scenarioId": "proofkit.spec-proof-core.workspace-lookup-cohort-and-anchor", + "witnessId": "proofkit.workspace-lookup.cohort-anchor-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_lookup_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "scenarioId": "proofkit.spec-proof-core.workspace-lookup-admission", + "witnessId": "proofkit.workspace-lookup.admission-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_lookup_admission_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "scenarioId": "proofkit.spec-proof-core.workspace-lookup-byte-bound", + "witnessId": "proofkit.workspace-lookup.byte-bound-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/requirementbrowser/workspace_page_test.go", + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-036", + "scenarioId": "proofkit.spec-proof-core.workspace-lookup-browser", + "witnessId": "proofkit.workspace-lookup.browser-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/lookup.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "scenarioId": "proofkit.spec-proof-core.workspace-responsive-panels", + "witnessId": "proofkit.workspace-panels.browser-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace-panels.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "scenarioId": "proofkit.spec-proof-core.workspace-error-authority", + "witnessId": "proofkit.workspace-errors.browser-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace-errors.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "scenarioId": "proofkit.spec-proof-core.workspace-request-lifecycle", + "witnessId": "proofkit.workspace-requests.browser-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace-request-lifecycle.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-037", + "scenarioId": "proofkit.spec-proof-core.workspace-exact-page-retry", + "witnessId": "proofkit.workspace-paging.browser-falsifier", + "witnessKind": "technical", + "witnessPath": "tests/browser/workspace-paging.spec.mjs", + "commandIds": ["proofkit.browser-check"], + "environmentClasses": ["local-node-browser"] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-022", "scenarioId": "proofkit.supply-chain-quality.browser-static-and-runtime-proof", diff --git a/proofkit/witness-plan.json b/proofkit/witness-plan.json index 9afd1d6..a56d795 100644 --- a/proofkit/witness-plan.json +++ b/proofkit/witness-plan.json @@ -796,6 +796,7 @@ "scripts/browser-proof-inputs.test.mjs", "scripts/browser-runtime-proof-inputs.v1.json", "scripts/browser-selection-authority.test.mjs", + "scripts/browser-workspace-requests.test.mjs", "scripts/diagnostic.mjs", "scripts/stable-json.mjs", "scripts/stable-json.test.mjs", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 73d593f..682339b 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,34 +1,26 @@ { "schemaVersion": 2, - "previousVersion": "0.10.1", - "version": "0.11.0", - "changeClass": "breaking", - "breakingChanges": [ - { - "changeId": "proofkit.repository-transaction.bound-terminal-replay", - "summary": "New terminal receipts use schema v2 to bind each completed transaction to its exact desired-state identity. Legacy schema-v1 receipts remain readable and recoverable, but cannot authorize acknowledgement replay; a newly reviewed plan is required. Roots containing v2 receipts are not compatible with older binaries." - } - ], + "previousVersion": "0.11.0", + "version": "0.12.0", + "changeClass": "compatible", + "breakingChanges": [], "additions": [ { - "changeId": "proofkit.agent-integration.managed-lifecycle", - "summary": "Add explicit integration plan, apply and recover routes for reviewed install, update and removal of the selected tool bootstrap. Preserve local edits and neighboring instructions with one native two-target transaction and a cooperative baseline." + "changeId": "proofkit.browser.lookup", + "summary": "Search and filter the complete admitted requirement cohort before bounded paging, navigate ordered specification children, and preserve original source anchors and exact omission counts without loading the full snapshot into the browser." }, { - "changeId": "proofkit.repository-transaction.desired-absence", - "summary": "Support exact desired absence, guarded deletion and recovery through versioned native journals while preserving present-only v1 bytes and identities. Bind idempotent current-state replay to the pending-state and retained-result checks under one native lock." + "changeId": "proofkit.browser.request-recovery", + "summary": "Distinguish correction, denied, stale, unavailable and retryable failures. Explicit Retry preserves the failed route, snapshot and complete page query, while superseded requests cannot restore stale content or request authority." }, { - "changeId": "proofkit.repository-transaction.recovery-observation", - "summary": "Preserve operational target-observation failures through recovery and both CLI consumers instead of presenting them as observed state mismatches. Keep target and control state unchanged on pre-effect refusal, including temporary-journal publication, and retain cleanup outcomes after the final committed effect." + "changeId": "proofkit.browser.responsive-shell", + "summary": "Add compact Browse and Inspector panels with native modal behavior on smaller viewports, stable keyboard focus, retained question drafts, source-bound Unicode selection and lazy boundary details." } ], "migration": { - "required": true, - "steps": [ - "For an acknowledgement retry backed by a legacy terminal receipt, run adopt materialize plan again and review both current identities before apply; do not reuse the old transaction identity.", - "Use this or a later supporting binary for roots containing v2 journals or terminal receipts. Completing recovery does not make retained v2 receipts downgrade-compatible; do not delete private control state as a downgrade shortcut." - ] + "required": false, + "steps": [] }, "platformRequirements": [ "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." @@ -43,7 +35,8 @@ "The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", "TSX source parsing remains unsupported.", "Managed integration baselines are cooperative byte/mode bookkeeping, not authenticated origin or protection against coordinated same-user edits. File lifecycle does not prove native host discovery, instruction loading, or approved-launcher invocation.", - "Desired-absence journals and newly retained terminal receipts use schema v2. Earlier binaries reject these records without effects. Present-only v1 plan/journal bytes and historical recovery remain supported; finishing recovery does not enable downgrade of retained v2 receipts." + "Desired-absence journals and newly retained terminal receipts use schema v2. Earlier binaries reject these records without effects. Present-only v1 plan/journal bytes and historical recovery remain supported; finishing recovery does not enable downgrade of retained v2 receipts.", + "Browser lookup and navigation are presentation-only fragments, not proof coverage or an external HTTP SDK. Annotations are session-bound; browser controls do not edit specifications or execute agents." ], "rollback": { "strategy": "previous_admitted_version" diff --git a/scripts/browser-proof-inputs.test.mjs b/scripts/browser-proof-inputs.test.mjs index fca3df6..79d33c9 100644 --- a/scripts/browser-proof-inputs.test.mjs +++ b/scripts/browser-proof-inputs.test.mjs @@ -388,7 +388,8 @@ test("owner resolution closes manifest, Go dependencies, and role-owned paths", }); test("workspace navigation excludes provider-falsified lifecycle waits", () => { - const source = readFileSync("tests/browser/workspace.spec.mjs", "utf8"); + const {inputPaths} = loadBrowserProofInputResolution(); + const source = inputPaths.filter(path => path.startsWith("tests/browser/") && path.endsWith(".mjs")).map(path => readFileSync(path, "utf8")).join("\n"); const lifecycleMethods = [...source.matchAll( /\bpage\.(goBack|goForward|goto|reload|waitForLoadState|waitForNavigation|waitForURL)\s*\(/g, )].map((match) => match[1]); diff --git a/scripts/browser-runtime-proof-inputs.v1.json b/scripts/browser-runtime-proof-inputs.v1.json index c1e266d..2f8062e 100644 --- a/scripts/browser-runtime-proof-inputs.v1.json +++ b/scripts/browser-runtime-proof-inputs.v1.json @@ -16,6 +16,7 @@ "scripts/browser-proof-inputs.mjs", "scripts/browser-proof-inputs.test.mjs", "scripts/browser-selection-authority.test.mjs", + "scripts/browser-workspace-requests.test.mjs", "scripts/diagnostic.mjs", "scripts/stable-json.mjs", "scripts/stable-json.test.mjs", diff --git a/scripts/browser-workspace-requests.test.mjs b/scripts/browser-workspace-requests.test.mjs new file mode 100644 index 0000000..42d46af --- /dev/null +++ b/scripts/browser-workspace-requests.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import {createServer} from "node:http"; +import test from "node:test"; + +import {fetchWorkspaceJSON, workspaceFailure, WorkspaceRequestError} from "../internal/command/requirementbrowser/assets/workspace-requests.js"; + +async function endpoint(t, respond) { + const received = Promise.withResolvers(); + const headers = Promise.withResolvers(); + const server = createServer((request, response) => { + respond(response); + received.resolve(response); + }); + server.on("clientError", (_error, socket) => socket.destroy()); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); + const nativeFetch = globalThis.fetch; + t.mock.method(globalThis, "fetch", async (...args) => { + const response = await nativeFetch(...args); + headers.resolve(); + return response; + }); + return {url: `http://127.0.0.1:${server.address().port}/`, received: received.promise, headers: headers.promise}; +} + +function incompleteJSON(response) { + response.writeHead(200, {"Content-Type": "application/json", "Content-Length": "200"}); + response.write('{"rows":'); +} + +test("a real body connection failure after successful headers remains retryable", async t => { + const fixture = await endpoint(t, incompleteJSON); + const outcome = fetchWorkspaceJSON(fixture.url, {}).catch(error => error); + await fixture.headers; + (await fixture.received).destroy(); + const error = await outcome; + assert(error instanceof WorkspaceRequestError); + assert.equal(error.status, 0); + assert.deepEqual(workspaceFailure(error), { + message: "The workspace could not be reached. Try this request again.", + action: "retry", lock: false, kind: "retryable", + }); +}); + +test("aborting a pending response body preserves cancellation rather than Retry", async t => { + const fixture = await endpoint(t, incompleteJSON); + const controller = new AbortController(); + const outcome = fetchWorkspaceJSON(fixture.url, {signal: controller.signal}).catch(error => error); + await fixture.headers; + controller.abort(); + const error = await outcome; + assert.equal(error.name, "AbortError"); + assert(!(error instanceof WorkspaceRequestError)); + assert.equal(workspaceFailure(error).action, "none"); +}); + +test("a complete malformed JSON body is sanitized and non-retryable", async t => { + const fixture = await endpoint(t, response => { + response.writeHead(200, {"Content-Type": "application/json"}); + response.end("private malformed body"); + }); + const error = await fetchWorkspaceJSON(fixture.url, {}).catch(error => error); + assert(error instanceof SyntaxError); + assert.deepEqual(workspaceFailure(error), { + message: "The admitted workspace is unavailable.", action: "none", lock: false, kind: "unavailable", + }); +}); + +test("a successful complete JSON body retains its admitted value", async t => { + const fixture = await endpoint(t, response => { + response.writeHead(200, {"Content-Type": "application/json"}); + response.end('{"requestId":"request.example","rows":[1]}'); + }); + assert.deepEqual(await fetchWorkspaceJSON(fixture.url, {}), {requestId: "request.example", rows: [1]}); +}); + +test("HTTP status owns recovery before an unconsumed malformed body", async t => { + const fixture = await endpoint(t, response => { + response.writeHead(409, {"Content-Type": "application/json", "Content-Length": "200"}); + response.write("private malformed body"); + }); + const error = await fetchWorkspaceJSON(fixture.url, {}).catch(error => error); + assert(error instanceof WorkspaceRequestError); + assert.equal(error.status, 409); + assert.deepEqual(workspaceFailure(error), { + message: "The workspace snapshot has changed.", action: "reload", lock: true, kind: "stale", + }); +}); diff --git a/tests/browser/lookup.spec.mjs b/tests/browser/lookup.spec.mjs new file mode 100644 index 0000000..48454bd --- /dev/null +++ b/tests/browser/lookup.spec.mjs @@ -0,0 +1,225 @@ +import {expect} from "@playwright/test"; +import {capacityTest, lookupTest as test} from "./workspace-test-harness.mjs"; +import {openWorkspace} from "./workspace-navigation-harness.mjs"; + +async function expectRows(page, ids) { + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveCount(ids.length); + await expect.poll(() => page.locator("#workspace-content [data-requirement-id]").evaluateAll(rows => rows.map(row => row.dataset.requirementId))).toEqual(ids); +} + +function ids(first, last) { + return Array.from({length: last - first + 1}, (_, index) => `REQ-B-${String(first + index).padStart(3, "0")}`); +} + +async function selectChild(page) { + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await page.getByRole("button", {name: "Child contracts", exact: true}).click(); +} + +capacityTest("maximum-node workspace keeps lazy navigation and full-cohort search bounded", async ({capacityURL, page}) => { + await openWorkspace(page, capacityURL); + await expectRows(page, ["REQ-A", ...ids(0, 62)]); + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li')).toHaveCount(64); + await expect(page.locator("[data-navigation-node]")).toHaveCount(65); + await page.getByRole("button", {name: "Next navigation page", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li').first()).toHaveAttribute("data-navigation-node", "spec.sibling.063"); + await expect(page.locator("[data-navigation-node]")).toHaveCount(65); + await page.getByRole("searchbox").fill("REQ-B-129"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expectRows(page, ["REQ-B-129"]); + await expect(page.locator('[data-requirement-id="REQ-B-129"] [data-anchor-id]')).toHaveText("State \u{1f9ed} e\u0301 keeps source identity."); +}); + +test("native lookup searches the full cohort, intersects scope, and preserves original handoff anchors", async ({lookupURL, page}) => { + const requests = []; + page.on("request", request => { + if (new URL(request.url()).pathname === "/api/v1/requirements") requests.push(request.postDataJSON()); + }); + await openWorkspace(page, lookupURL); + await expectRows(page, ["REQ-A", ...ids(0, 62)]); + await page.getByRole("button", {name: "Next specifications page", exact: true}).click(); + await expectRows(page, ids(63, 126)); + await page.getByRole("button", {name: "Next specifications page", exact: true}).click(); + await expectRows(page, [...ids(127, 129), "REQ-C"]); + await page.getByRole("button", {name: "Previous specifications page", exact: true}).click(); + await expectRows(page, ids(63, 126)); + + await selectChild(page); + await page.getByLabel("Owner", {exact: true}).selectOption("owner.b"); + await page.getByLabel("Lifecycle", {exact: true}).selectOption("active"); + await expectRows(page, ids(2, 65)); + await page.getByRole("searchbox").fill(" STATE \u{1f9ed} e\u0301 "); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expectRows(page, ["REQ-B-129"]); + expect(requests.at(-1).query).toEqual({nodeId: "spec.child", ownerId: "owner.b", lifecycleState: "active", searchText: " STATE \u{1f9ed} e\u0301 ", offset: 0, maxRecords: 64}); + await expect(page.locator(".page-summary")).toHaveText("Showing 1-1 of 1 specifications records."); + await page.getByRole("button", {name: "Select invariant", exact: true}).click(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Does the original source identity survive lookup?"); + await page.getByRole("button", {name: "Create handoff packet", exact: true}).click(); + await expect(page.locator("#handoff-status")).toHaveText("Handoff packet created."); + const packet = JSON.parse(await page.locator("#handoff-packet").textContent()); + expect(packet.annotations[0]).toMatchObject({anchor: {anchorId: "requirement:REQ-B-129:invariant", requirementId: "REQ-B-129", jsonPointer: "/projections/requirementSources/1/requirements/129/invariant"}, exactQuote: "State \u{1f9ed} e\u0301 keeps source identity."}); + + await page.getByRole("searchbox").fill("\u00e9"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expectRows(page, []); + await expect(page.locator("#workspace-content [role=status]")).toHaveAttribute("data-state", "no-match"); + await expect(page.locator("#selected-context li")).toHaveCount(0); + await expect(page.getByRole("textbox", {name: "Question", exact: true})).toHaveValue("Does the original source identity survive lookup?"); +}); + +test("navigation pages retain parent scope and overview-only nodes select no requirements", async ({lookupURL, page}) => { + await openWorkspace(page, lookupURL); + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li')).toHaveCount(64); + await page.getByRole("button", {name: "Next navigation page", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li').first()).toHaveAttribute("data-navigation-node", "spec.sibling.063"); + await page.getByRole("button", {name: "Next navigation page", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li')).toHaveCount(3); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li').first()).toHaveAttribute("data-navigation-node", "spec.sibling.127"); + await page.getByRole("button", {name: "Sibling 129", exact: true}).click(); + await expectRows(page, []); + await expect(page.locator("#selected-scope")).toHaveText("Workspace root / Sibling 129"); + await page.getByRole("button", {name: "Previous navigation page", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li').first()).toHaveAttribute("data-navigation-node", "spec.sibling.063"); + await expect(page.locator("#selected-scope")).toHaveText("Workspace root / Sibling 129"); +}); + +for (const failure of [429, 500, 503, "network", "body"]) { + test(`lookup Retry preserves every non-default operand after ${failure}`, async ({lookupURL, page}) => { + const attempts = []; + if (failure === "body") { + await page.addInitScript(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + let failed = false; + globalThis.fetch = async (input, init) => { + const response = await nativeFetch(input, init); + const query = typeof init?.body === "string" ? JSON.parse(init.body)?.query : null; + if (!failed && query?.offset === 64 && query?.searchText === "Capability") { + failed = true; + await response.arrayBuffer(); + return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private body transport failure")); }, + }), {status: 200, headers: {"Content-Type": "application/json"}}); + } + return response; + }; + }); + } + await page.route("**/api/v1/requirements", async route => { + const request = route.request(); + const body = request.postDataJSON(); + if (body.query.offset !== 64 || body.query.searchText !== "Capability") return route.continue(); + attempts.push({method: request.method(), path: new URL(request.url()).pathname, body}); + if (attempts.length === 1) { + if (failure === "network") return route.abort("failed"); + if (failure === "body") return route.continue(); + return route.fulfill({status: failure, body: "private failure detail"}); + } + return route.continue(); + }); + await openWorkspace(page, lookupURL); + await selectChild(page); + await page.getByLabel("Owner", {exact: true}).selectOption("owner.b"); + await page.getByLabel("Lifecycle", {exact: true}).selectOption("active"); + await page.getByRole("searchbox").fill("Capability"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expectRows(page, ids(2, 65)); + await page.getByRole("button", {name: "Select invariant", exact: true}).first().click(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Keep this draft."); + await page.getByRole("button", {name: "Next specifications page", exact: true}).click(); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toBeVisible(); + expect(attempts).toHaveLength(1); + await expect(page.locator("#selected-context li")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText("private failure detail"); + await expect(page.locator("body")).not.toContainText("private body transport failure"); + await page.getByRole("searchbox").fill("unsent different search"); + await page.getByRole("button", {name: "Retry", exact: true}).click(); + await expectRows(page, ids(66, 128)); + expect(attempts).toHaveLength(2); + const expectedQuery = {searchText: "Capability", nodeId: "spec.child", ownerId: "owner.b", lifecycleState: "active", maxRecords: 64, offset: 64}; + for (const attempt of attempts) { + expect(attempt.method).toBe("POST"); + expect(attempt.path).toBe("/api/v1/requirements"); + expect(attempt.body).toEqual({query: expectedQuery, requestId: expect.stringMatching(/^browser\.specifications\./), snapshotId: expect.stringMatching(/^sha256:[0-9a-f]{64}$/)}); + } + expect(attempts[1].body.snapshotId).toBe(attempts[0].body.snapshotId); + expect(attempts[1].body.requestId).not.toBe(attempts[0].body.requestId); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); + await expect(page.getByRole("textbox", {name: "Question", exact: true})).toHaveValue("Keep this draft."); + }); +} + +test("navigation Retry preserves its parent and page without invalidating content selection", async ({lookupURL, page}) => { + const attempts = []; + await page.route("**/api/v1/navigation", route => { + const request = route.request(); + const body = request.postDataJSON(); + if (body.query.offset !== 64) return route.continue(); + attempts.push({method: request.method(), path: new URL(request.url()).pathname, body}); + return attempts.length === 1 ? route.fulfill({status: 503, body: "private navigation detail"}) : route.continue(); + }); + await openWorkspace(page, lookupURL); + await page.getByRole("button", {name: "Select invariant", exact: true}).first().click(); + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await page.getByRole("button", {name: "Next navigation page", exact: true}).click(); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toBeVisible(); + expect(attempts).toHaveLength(1); + await expect(page.locator("#selected-context li")).toHaveText("Root scope remains independent."); + await page.getByRole("button", {name: "Retry", exact: true}).click(); + await expect(page.locator('[data-navigation-branch="1"] > .navigation-nodes > li').first()).toHaveAttribute("data-navigation-node", "spec.sibling.063"); + expect(attempts).toHaveLength(2); + for (const attempt of attempts) { + expect(attempt.method).toBe("POST"); + expect(attempt.path).toBe("/api/v1/navigation"); + expect(attempt.body).toEqual({query: {parentNodeId: "spec.root", offset: 64, maxRecords: 64}, requestId: expect.stringMatching(/^browser\.navigation\./), snapshotId: expect.stringMatching(/^sha256:[0-9a-f]{64}$/)}); + } + expect(attempts[1].body.snapshotId).toBe(attempts[0].body.snapshotId); + expect(attempts[1].body.requestId).not.toBe(attempts[0].body.requestId); + await expect(page.locator("#selected-context li")).toHaveText("Root scope remains independent."); +}); + +test("variable-size admitted pages return to exact visited offsets", async ({lookupURL, page}) => { + const offsets = []; + await page.route("**/api/v1/requirements", async route => { + const body = route.request().postDataJSON(); + offsets.push(body.query.offset); + const response = await route.fetch({postData: {...body, query: {...body.query, maxRecords: body.query.offset === 0 ? 2 : 1}}}); + return route.fulfill({response}); + }); + await openWorkspace(page, lookupURL); + const rows = page.locator("#workspace-content [data-requirement-id]"); + await expect(rows).toHaveCount(2); + expect(await rows.evaluateAll(elements => elements.map(element => element.dataset.requirementId))).toEqual(["REQ-A", "REQ-B-000"]); + for (const id of ["REQ-B-001", "REQ-B-002"]) { + await page.getByRole("button", {name: "Next specifications page", exact: true}).click(); + await expect(rows).toHaveAttribute("data-requirement-id", id); + } + await page.getByRole("button", {name: "Previous specifications page", exact: true}).click(); + await expect(rows).toHaveAttribute("data-requirement-id", "REQ-B-001"); + await page.getByRole("button", {name: "Previous specifications page", exact: true}).click(); + await expect(rows).toHaveCount(2); + expect(await rows.evaluateAll(elements => elements.map(element => element.dataset.requirementId))).toEqual(["REQ-A", "REQ-B-000"]); + expect(offsets).toEqual([0, 2, 3, 2, 0]); +}); + +test("deep navigation retains the selected path within 256 rows and preserves disclosure focus", async ({lookupURL, page}) => { + await openWorkspace(page, lookupURL); + for (const label of ["Workspace root", "Child contracts", "Nested contracts", "Depth 1", "Depth 2", "Depth 3", "Depth 4", "Depth 5"]) { + const disclosure = page.getByRole("button", {name: `Expand ${label}`, exact: true}); + await disclosure.focus(); + await disclosure.press("Enter"); + await expect(page.getByRole("button", {name: `Collapse ${label}`, exact: true})).toBeFocused(); + await expect.poll(() => page.locator("#spec-navigation [data-navigation-node]").count()).toBeLessThanOrEqual(256); + } + const deepestSelection = page.getByRole("button", {name: "Depth 6", exact: true}); + await deepestSelection.focus(); + await deepestSelection.press("Enter"); + await expect(page.locator("#selected-scope")).toHaveText("Workspace root / Child contracts / Nested contracts / Depth 1 / Depth 2 / Depth 3 / Depth 4 / Depth 5 / Depth 6"); + await expect(page.getByRole("button", {name: "Depth 6", exact: true})).toBeFocused(); + await page.locator('[data-navigation-branch="1"] > .navigation-paging').getByRole("button", {name: "Show sibling page", exact: true}).click(); + await expect(page.getByRole("button", {name: "Sibling 062", exact: true})).toBeVisible(); + await expect(page.locator("#selected-scope")).toContainText("Depth 6"); + expect(await page.locator("#spec-navigation [data-navigation-node]").count()).toBeLessThanOrEqual(256); +}); diff --git a/tests/browser/workspace-errors.spec.mjs b/tests/browser/workspace-errors.spec.mjs new file mode 100644 index 0000000..48820a0 --- /dev/null +++ b/tests/browser/workspace-errors.spec.mjs @@ -0,0 +1,140 @@ +import {expect} from "@playwright/test"; +import {lookupTest as test} from "./workspace-test-harness.mjs"; +import {admittedWorkspaceURL, navigateWorkspace, openWorkspace} from "./workspace-navigation-harness.mjs"; + +const states = [ + {status: 400, message: "The query could not be accepted. Check its fields and submit again.", kind: "correction", action: null}, + {status: 403, message: "Access to this workspace was denied.", kind: "denied", action: null}, + {status: 409, message: "The workspace snapshot has changed.", kind: "stale", action: "Reload workspace"}, + {status: 404, message: "The admitted workspace is unavailable.", kind: "unavailable", action: null}, +]; + +for (const operation of ["requirements", "navigation"]) { + for (const failure of states) { + test(`${operation} ${failure.status} has its exact recovery authority`, async ({lookupURL, page}) => { + let fail = false; + const attempts = []; + const protectedCalls = []; + page.on("request", request => { if (new URL(request.url()).pathname.startsWith("/api/")) protectedCalls.push(request.url()); }); + await page.route(`**/api/v1/${operation}`, route => { + if (!fail) return route.continue(); + attempts.push(route.request().postDataJSON()); + return route.fulfill({status: failure.status, body: "private recovery detail"}); + }); + await openWorkspace(page, lookupURL); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + await expect(page.getByRole("button", {name: "Expand Workspace root", exact: true})).toBeVisible(); + await page.getByRole("button", {name: "Select invariant", exact: true}).first().click(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Keep the draft after failure."); + fail = true; + if (operation === "requirements") { + await page.getByRole("searchbox").fill("Root"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + } else { + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + } + const container = page.locator(operation === "requirements" ? "#workspace-content" : "#spec-navigation"); + await expect(container.getByRole("alert")).toHaveText(failure.message); + await expect(container.getByRole("alert")).toHaveAttribute("data-state", failure.kind); + await expect(page.locator("body")).not.toContainText("private recovery detail"); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); + await expect(page.getByRole("button", {name: "Reload workspace", exact: true})).toHaveCount(failure.action ? 1 : 0); + expect(attempts).toHaveLength(1); + await expect(page.locator("#selected-context li")).toHaveCount(operation === "requirements" ? 0 : 1); + await expect(page.getByRole("textbox", {name: "Question", exact: true})).toHaveValue("Keep the draft after failure."); + + if (failure.status === 403 || failure.status === 409) { + const count = protectedCalls.length; + const controls = page.locator("[data-protected-request]"); + expect(await controls.count()).toBeGreaterThan(8); + expect(await controls.evaluateAll(elements => elements.every(element => element.disabled))).toBe(true); + await controls.evaluateAll(elements => { for (const element of elements) element.click(); }); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Local draft remains editable."); + expect(protectedCalls).toHaveLength(count); + } + if (failure.status === 400) { + fail = false; + const recovery = page.waitForRequest(request => new URL(request.url()).pathname === `/api/v1/${operation}`); + if (operation === "requirements") { + await page.getByRole("searchbox").fill("REQ-B-129"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveAttribute("data-requirement-id", "REQ-B-129"); + } else { + await page.getByRole("button", {name: "Collapse Workspace root", exact: true}).click(); + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await expect(page.getByRole("button", {name: "Child contracts", exact: true})).toBeVisible(); + } + expect((await recovery).postDataJSON().requestId).not.toBe(attempts[0].requestId); + } + if (failure.status === 409) { + fail = false; + let navigations = 0; + page.on("request", request => { if (request.isNavigationRequest() && request.frame() === page.mainFrame()) navigations += 1; }); + await navigateWorkspace(page, admittedWorkspaceURL(lookupURL), async token => { + await page.getByRole("button", {name: "Reload workspace", exact: true}).evaluate(button => button.click()); + return token; + }, "Explicit workspace reload failed"); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + expect(navigations).toBe(1); + } + if (failure.status === 404) { + await expect(container.locator('[data-state="no-match"]')).toHaveCount(0); + await expect(page.getByRole("searchbox")).toBeEnabled(); + } + }); + } +} + +test("manifest Retry preserves GET and does not invent query or snapshot input", async ({lookupURL, page}) => { + const attempts = []; + await page.route("**/api/v1/manifest", route => { + const request = route.request(); + attempts.push({method: request.method(), path: new URL(request.url()).pathname, body: request.postData()}); + return attempts.length === 1 ? route.fulfill({status: 503, body: "private bootstrap detail"}) : route.continue(); + }); + await openWorkspace(page, lookupURL); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toBeVisible(); + expect(attempts).toEqual([{method: "GET", path: "/api/v1/manifest", body: null}]); + await page.getByRole("button", {name: "Retry", exact: true}).click(); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + expect(attempts).toEqual(Array(2).fill({method: "GET", path: "/api/v1/manifest", body: null})); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); +}); + +for (const optional of [{route: "diff", button: "Diff"}, {route: "graph", button: "Traceability"}]) { + test(`${optional.route} 404 is unavailable rather than a successful empty result`, async ({baseURL, page}) => { + let count = 0; + await page.route(`**/api/v1/${optional.route}`, route => { + count += 1; + return route.fulfill({status: 404, body: "private optional detail"}); + }); + await openWorkspace(page, baseURL); + await page.getByRole("button", {name: optional.button, exact: true}).click(); + await expect(page.locator("#workspace-content [role=alert]")).toHaveText("This workspace view is unavailable."); + await expect(page.locator("#workspace-content [role=alert]")).toHaveAttribute("data-state", "optional-unavailable"); + expect(count).toBe(1); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); + await expect(page.getByRole("button", {name: "Specifications", exact: true})).toBeEnabled(); + }); +} + +test("an explicit new lookup invalidates a detached Retry action", async ({lookupURL, page}) => { + const searches = []; + await page.route("**/api/v1/requirements", route => { + const body = route.request().postDataJSON(); + searches.push(body.query.searchText ?? ""); + return body.query.searchText === "old request" ? route.fulfill({status: 503, body: "{}"}) : route.continue(); + }); + await openWorkspace(page, lookupURL); + await page.getByRole("searchbox").fill("old request"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + const retry = page.getByRole("button", {name: "Retry", exact: true}); + await expect(retry).toBeVisible(); + const staleAction = await retry.elementHandle(); + await page.getByRole("searchbox").fill("REQ-B-129"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveAttribute("data-requirement-id", "REQ-B-129"); + await staleAction.evaluate(button => button.click()); + await expect(page.locator("body")).toHaveAttribute("data-state", "specifications"); + expect(searches).toEqual(["", "old request", "REQ-B-129"]); +}); diff --git a/tests/browser/workspace-locking.spec.mjs b/tests/browser/workspace-locking.spec.mjs new file mode 100644 index 0000000..9bd1f94 --- /dev/null +++ b/tests/browser/workspace-locking.spec.mjs @@ -0,0 +1,207 @@ +import {expect} from "@playwright/test"; +import {lookupTest, pagingTest, test} from "./workspace-test-harness.mjs"; +import {openWorkspace} from "./workspace-navigation-harness.mjs"; + +for (const status of [403, 409]) { + lookupTest(`requirements content commit preserves navigation ${status} lock`, async ({lookupURL, page}) => { + await assertLockedContentCommit(page, lookupURL, "requirements", status); + }); + for (const view of ["diff", "graph"]) { + pagingTest(`${view} content commit preserves navigation ${status} lock`, async ({pagingURL, page}) => { + await assertLockedContentCommit(page, pagingURL, view, status); + }); + } +} + +for (const outcome of [200, 400]) { + test(`pending handoff survives content refresh and releases after ${outcome}`, async ({baseURL, page}) => { + const handoff = await holdFirstHandoff(page, outcome); + try { + await startPendingHandoff(page, baseURL, handoff.started); + await page.getByRole("button", {name: "Specifications", exact: true}).click(); + await expect(page.locator("#workspace-content")).toHaveAttribute("aria-busy", "false"); + await page.getByRole("button", {name: "Select invariant", exact: true}).first().click(); + const question = page.getByRole("textbox", {name: "Question", exact: true}); + await question.fill("Second intentional question."); + const submit = page.getByRole("button", {name: "Create handoff packet", exact: true}); + await expect(submit).toBeDisabled(); + await submit.dispatchEvent("click"); + expect(await handoff.count()).toBe(1); + + handoff.release(); + const packet = page.locator("#handoff-packet"); + if (outcome === 200) { + await expect(page.locator("#handoff-status")).toHaveText("Handoff packet created."); + expect(JSON.parse(await packet.textContent()).annotations.map(item => item.question)).toEqual(["First pending question."]); + } else { + await expect(page.locator("#handoff-status")).toHaveText("The handoff packet could not be created."); + await expect(packet).toBeEmpty(); + } + await expect(question).toHaveValue("Second intentional question."); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(packet).toContainText("Second intentional question."); + expect(JSON.parse(await packet.textContent()).annotations.map(item => item.question)).toEqual(["Second intentional question."]); + expect(await handoff.count()).toBe(2); + } finally { handoff.release(); } + }); +} + +for (const status of [403, 409]) { + for (const first of ["handoff", "lock"]) { + test(`pending handoff preserves ${status} authority when ${first} finishes first`, async ({baseURL, page}) => { + const handoff = await holdFirstHandoff(page, 200); + const lock = Promise.withResolvers(); + const lockStarted = Promise.withResolvers(); + try { + await startPendingHandoff(page, baseURL, handoff.started); + await page.route("**/api/v1/requirements", async route => { + lockStarted.resolve(); + await lock.promise; + return route.fulfill({status, body: "private refresh detail"}); + }); + await page.getByRole("button", {name: "Specifications", exact: true}).click(); + await lockStarted.promise; + const expectLock = async () => { + const message = status === 403 ? "Access to this workspace was denied." : "The workspace snapshot has changed."; + await expect(page.locator("#workspace-content [role=alert]")).toHaveText(message); + }; + const expectHandoff = () => expect(page.locator("#handoff-status")).toHaveText("Handoff packet created."); + if (first === "handoff") { + handoff.release(); + await expectHandoff(); + lock.resolve(); + await expectLock(); + } else { + lock.resolve(); + await expectLock(); + handoff.release(); + await expectHandoff(); + } + const submit = page.getByRole("button", {name: "Create handoff packet", exact: true}); + await expect(submit).toBeDisabled(); + await submit.dispatchEvent("click"); + expect(await handoff.count()).toBe(1); + await expect(page.getByRole("textbox", {name: "Question", exact: true})).toHaveValue("First pending question."); + await expect(page.getByRole("button", {name: "Reload workspace", exact: true})).toHaveCount(status === 409 ? 1 : 0); + await expect(page.locator("body")).not.toContainText("private refresh detail"); + } finally { + lock.resolve(); + handoff.release(); + } + }); + } +} + +async function holdFirstHandoff(page, outcome) { + const barrier = Promise.withResolvers(); + const started = Promise.withResolvers(); + await page.addInitScript(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + globalThis.__handoffFetchCount = 0; + globalThis.fetch = (input, init) => { + const url = new URL(typeof input === "string" ? input : input.url, location.href); + if (url.pathname === "/api/v1/handoff") globalThis.__handoffFetchCount += 1; + return nativeFetch(input, init); + }; + }); + let count = 0; + await page.route("**/api/v1/handoff", async route => { + count += 1; + if (count !== 1) return route.continue(); + const response = outcome === 200 ? await route.fetch() : + await route.fetch({postData: {...route.request().postDataJSON(), annotations: []}}); + expect(response.status()).toBe(outcome); + started.resolve(); + await barrier.promise; + return route.fulfill({response}); + }); + return {release: barrier.resolve, started: started.promise, count: () => page.evaluate(() => globalThis.__handoffFetchCount)}; +} + +async function startPendingHandoff(page, url, started) { + await page.setViewportSize({width: 1920, height: 1080}); + await openWorkspace(page, url); + const select = page.getByRole("button", {name: "Select invariant", exact: true}).first(); + await expect(select).toBeVisible(); + await select.click(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("First pending question."); + const submit = page.getByRole("button", {name: "Create handoff packet", exact: true}); + await submit.click(); + await started; + await expect(submit).toBeDisabled(); +} + +async function assertLockedContentCommit(page, url, view, status) { + let releaseNavigation; + const navigationBarrier = new Promise(resolve => { releaseNavigation = resolve; }); + let releaseContent; + const contentBarrier = new Promise(resolve => { releaseContent = resolve; }); + let contentStarted; + const started = new Promise(resolve => { contentStarted = resolve; }); + await page.addInitScript(() => { + const nativeFetch = globalThis.fetch.bind(globalThis); + globalThis.__protectedFetchCount = 0; + globalThis.fetch = (input, init) => { + const url = new URL(typeof input === "string" ? input : input.url, location.href); + if (url.pathname.startsWith("/api/")) globalThis.__protectedFetchCount += 1; + return nativeFetch(input, init); + }; + }); + await page.route("**/api/v1/navigation", async route => { + await navigationBarrier; + return route.fulfill({status, body: "private navigation detail"}); + }); + await page.route(`**/api/v1/${view}`, async route => { + const body = route.request().postDataJSON(); + const query = view === "requirements" ? body.query : { + ...body.query, maxRecords: 1, ...view === "graph" ? {maxEdges: 1} : {}, + }; + const response = await route.fetch({postData: {...body, query}}); + const delayed = view === "requirements" ? query.offset === 64 : + query.offset === 1 && (view !== "graph" || query.edgeOffset === 1); + if (delayed) { + contentStarted(); + await contentBarrier; + } + return route.fulfill({response}); + }); + try { + await openWorkspace(page, url); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toBeVisible(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Keep this local question."); + if (view !== "requirements") { + await page.getByRole("button", {name: view === "diff" ? "Diff" : "Traceability", exact: true}).click(); + } + await page.getByRole("button", {name: `Next ${view === "requirements" ? "specifications" : view} page`, exact: true}).click(); + if (view === "graph") await page.getByRole("button", {name: "Next graph relation page", exact: true}).click(); + await started; + releaseNavigation(); + const message = status === 403 ? "Access to this workspace was denied." : "The workspace snapshot has changed."; + await expect(page.locator("#spec-navigation [role=alert]")).toHaveText(message); + releaseContent(); + await expect(page.locator("#workspace-content")).toHaveAttribute("aria-busy", "false"); + await expect(page.getByRole("button", {name: `Previous ${view === "requirements" ? "specifications" : view} page`, exact: true})).toBeVisible(); + if (view === "requirements") { + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveCount(64); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-B-063"); + } else if (view === "diff") { + await expect(page.locator("#workspace-content article > p").first()).toHaveText("/requirements/REQ-CONSUMER-001/riskClass"); + } else { + await expect(page.getByRole("button", {name: "Previous graph relation page", exact: true})).toBeVisible(); + await expect(page.locator('table[data-identity-kind="edge"] tbody tr')).toHaveCount(1); + } + const controls = page.locator("[data-protected-request]"); + expect(await controls.evaluateAll(elements => elements.filter(element => !element.disabled).map(element => element.textContent))).toEqual([]); + const before = await page.evaluate(() => globalThis.__protectedFetchCount); + await controls.evaluateAll(elements => { for (const element of elements) element.click(); }); + expect(await page.evaluate(() => globalThis.__protectedFetchCount)).toBe(before); + await expect(page.getByRole("textbox", {name: "Question", exact: true})).toHaveValue("Keep this local question."); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Local draft still editable."); + await expect(page.getByRole("button", {name: "Reload workspace", exact: true})).toHaveCount(status === 409 ? 1 : 0); + await expect(page.locator("body")).not.toContainText("private navigation detail"); + } finally { + releaseNavigation(); + releaseContent(); + } +} diff --git a/tests/browser/workspace-navigation-harness.mjs b/tests/browser/workspace-navigation-harness.mjs new file mode 100644 index 0000000..467a6a3 --- /dev/null +++ b/tests/browser/workspace-navigation-harness.mjs @@ -0,0 +1,76 @@ +import {expect} from "@playwright/test"; + +const workspaceNavigationToken = "proofkit.workspace-navigation.scheduled"; + +export function admittedWorkspaceURL(baseURL) { + if (typeof baseURL !== "string") throw new Error("Workspace base URL is unavailable"); + const url = new URL(baseURL); + if ( + url.protocol !== "http:" + || url.hostname !== "127.0.0.1" + || url.port === "" + || url.username !== "" + || url.password !== "" + || url.pathname !== "/" + || url.search !== "" + || url.hash !== "" + ) throw new Error("Workspace base URL is outside the admitted local origin"); + return url.href; +} + +export function isWorkspaceNavigationResponse(candidate, workspaceURL, mainFrame) { + const request = candidate.request(); + return candidate.url() === workspaceURL + && request.isNavigationRequest() + && request.frame() === mainFrame; +} + +export async function navigateWorkspace(page, workspaceURL, trigger, responseError) { + const controller = new AbortController(); + const mainFrame = page.mainFrame(); + const responsePromise = page.waitForResponse( + (candidate) => isWorkspaceNavigationResponse(candidate, workspaceURL, mainFrame), + {signal: controller.signal}, + ); + try { + const token = await trigger(workspaceNavigationToken); + if (token !== workspaceNavigationToken) { + throw new Error("Workspace navigation trigger token is invalid"); + } + const response = await responsePromise; + if (!response.ok()) throw new Error(responseError); + await expect( + page.getByRole("heading", {name: "browser.fixture.workspace", exact: true}), + ).toBeVisible(); + } catch (error) { + controller.abort(); + await responsePromise.catch(() => undefined); + throw error; + } +} + +export async function openWorkspace(page, baseURL) { + const workspaceURL = admittedWorkspaceURL(baseURL); + await navigateWorkspace( + page, + workspaceURL, + (token) => page.evaluate(({target, value}) => { + window.setTimeout(() => window.location.assign(target), 0); + return value; + }, {target: workspaceURL, value: token}), + "Workspace navigation did not return a successful response", + ); +} + +export async function reloadWorkspace(page, baseURL) { + const workspaceURL = admittedWorkspaceURL(baseURL); + await navigateWorkspace( + page, + workspaceURL, + (token) => page.evaluate((value) => { + window.setTimeout(() => window.location.reload(), 0); + return value; + }, token), + "Workspace reload did not return a successful response", + ); +} diff --git a/tests/browser/workspace-paging.spec.mjs b/tests/browser/workspace-paging.spec.mjs new file mode 100644 index 0000000..5a75378 --- /dev/null +++ b/tests/browser/workspace-paging.spec.mjs @@ -0,0 +1,56 @@ +import {expect} from "@playwright/test"; +import {pagingTest as test} from "./workspace-test-harness.mjs"; +import {openWorkspace} from "./workspace-navigation-harness.mjs"; + +for (const view of ["diff", "graph"]) { + test(`${view} Retry preserves every non-default page operand through native admission`, async ({pagingURL, page}) => { + const failedAttempts = []; + await page.route(`**/api/v1/${view}`, async route => { + const request = route.request(); + const body = request.postDataJSON(); + const target = body.query.offset === 1 && (view === "diff" || body.query.edgeOffset === 1); + if (target) { + failedAttempts.push({method: request.method(), path: new URL(request.url()).pathname, body}); + if (failedAttempts.length === 1) return route.fulfill({status: 503, body: "private page detail"}); + } + // A smaller admitted server window exercises client pagination without + // inventing graph edges, diff changes, identities, or response counts. + const query = {...body.query, maxRecords: 1, ...view === "graph" ? {maxEdges: 1} : {}}; + const response = await route.fetch({postData: {...body, query}}); + return route.fulfill({response}); + }); + await openWorkspace(page, pagingURL); + await expect(page.getByRole("button", {name: "Select invariant", exact: true})).toBeVisible(); + await page.getByRole("button", {name: view === "diff" ? "Diff" : "Traceability", exact: true}).click(); + await page.getByRole("button", {name: `Next ${view} page`, exact: true}).click(); + if (view === "graph") await page.getByRole("button", {name: "Next graph relation page", exact: true}).click(); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toBeVisible(); + expect(failedAttempts).toHaveLength(1); + await page.getByRole("searchbox").fill("Unsubmitted text must not change this operation."); + await page.getByRole("button", {name: "Retry", exact: true}).click(); + await expect(page.locator("#workspace-content")).toHaveAttribute("aria-busy", "false"); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); + expect(failedAttempts).toHaveLength(2); + for (const attempt of failedAttempts) { + expect(attempt).toEqual({ + method: "POST", path: `/api/v1/${view}`, + body: { + requestId: expect.stringMatching(new RegExp(`^browser\\.${view}\\.`)), + snapshotId: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + query: view === "diff" ? {offset: 1, maxRecords: 512} : {offset: 1, edgeOffset: 1, maxRecords: 256, maxEdges: 2048}, + }, + }); + } + expect(failedAttempts[1].body.snapshotId).toBe(failedAttempts[0].body.snapshotId); + expect(failedAttempts[1].body.requestId).not.toBe(failedAttempts[0].body.requestId); + if (view === "diff") { + await expect(page.locator("#workspace-content article > p").first()).toHaveText("/requirements/REQ-CONSUMER-001/riskClass"); + await expect(page.locator("#workspace-content article > pre")).toHaveText('"high"\n->\n"medium"'); + } else { + await expect(page.locator('table[data-identity-kind="node"] tbody tr')).toHaveCount(2); + expect(await page.locator('table[data-identity-kind="node"] tbody tr').evaluateAll(rows => rows.map(row => row.dataset.identity))).toEqual(["code:code.repository", "code:code.retry"]); + await expect(page.locator('table[data-identity-kind="edge"] tbody tr')).toHaveCount(1); + await expect(page.locator('table[data-identity-kind="edge"] tbody td').nth(1)).toHaveText("contains"); + } + }); +} diff --git a/tests/browser/workspace-panels.spec.mjs b/tests/browser/workspace-panels.spec.mjs new file mode 100644 index 0000000..168d5ac --- /dev/null +++ b/tests/browser/workspace-panels.spec.mjs @@ -0,0 +1,137 @@ +import {expect} from "@playwright/test"; +import {analyzeAxe, assertAxeTestComplete, initializeAxe} from "./axe-harness.mjs"; +import {openWorkspace} from "./workspace-navigation-harness.mjs"; +import {lookupTest} from "./workspace-test-harness.mjs"; + +const test = lookupTest.extend({ + axePage: async ({page}, use) => { + await initializeAxe(page); + try { await use(page); } + finally { assertAxeTestComplete(page); } + }, +}); + +for (const panel of ["navigation", "inspector"]) { + lookupTest(`desktop-to-mobile focus returns to the corresponding ${panel} opener`, async ({lookupURL, page}) => { + await page.setViewportSize({width: 1440, height: 900}); + await openWorkspace(page, lookupURL); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toBeVisible(); + await page.getByRole("button", {name: "Select invariant", exact: true}).first().click(); + await page.getByRole("textbox", {name: "Question", exact: true}).fill("Preserve the selected source and draft."); + const selected = await page.locator("#selected-context li").allTextContents(); + await page.locator(panel === "navigation" ? "#requirement-search" : "#annotation-question").focus(); + await page.setViewportSize({width: 390, height: 844}); + await expect(page.locator(`#workspace-${panel}`)).not.toBeVisible(); + await expect(page.locator(`#open-${panel}`)).toBeFocused(); + await expect(page.locator("dialog:modal")).toHaveCount(0); + await expect(page.locator("body")).toHaveAttribute("data-modal-open", "false"); + expect(await page.locator("#selected-context li").allTextContents()).toEqual(selected); + await expect(page.locator("#annotation-question")).toHaveValue("Preserve the selected source and draft."); + }); +} + +lookupTest("desktop layout does not acquire focus without user activation", async ({lookupURL, page}) => { + await page.setViewportSize({width: 1440, height: 900}); + await openWorkspace(page, lookupURL); + await expect(page.locator("[data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + await expect(page.locator("body")).toBeFocused(); + await page.getByRole("searchbox").focus(); + await page.setViewportSize({width: 1920, height: 1080}); + await expect(page.getByRole("searchbox")).toBeFocused(); + await page.getByRole("button", {name: "Close navigation", exact: true}).click(); + await expect(page.getByRole("button", {name: "Toggle specification navigation", exact: true})).toBeFocused(); + await page.getByRole("button", {name: "Toggle specification navigation", exact: true}).click(); + await expect(page.getByRole("searchbox")).toBeFocused(); +}); + +test("mobile inspector commits source selection, contains focus, and preserves the draft through resize", async ({lookupURL, axePage: page}) => { + await page.setViewportSize({width: 390, height: 844}); + await openWorkspace(page, lookupURL); + await expect(page.locator("[data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + const first = await page.locator("[data-anchor-id]").first().boundingBox(); + expect(first).not.toBeNull(); + expect(first.y).toBeGreaterThan(0); + expect(first.y + first.height).toBeLessThan(844); + await expect(page.locator("#workspace-navigation")).not.toBeVisible(); + await expect(page.locator("#workspace-inspector")).not.toBeVisible(); + await page.getByRole("button", {name: "Toggle specification navigation", exact: true}).click(); + await expect(page.getByRole("searchbox")).toBeFocused(); + await page.getByRole("searchbox").fill("REQ-B-129"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expect(page.locator("#workspace-navigation")).not.toBeVisible(); + const invariant = page.locator('[data-requirement-id="REQ-B-129"] [data-anchor-id]'); + await expect(invariant).toHaveText("State \u{1f9ed} e\u0301 keeps source identity."); + await invariant.evaluate(element => { + const range = document.createRange(); + range.setStart(element.firstChild, 6); + range.setEnd(element.firstChild, 8); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + await expect(page.locator("#selected-context li")).toHaveText("\u{1f9ed}"); + const opener = page.getByRole("button", {name: "Toggle question inspector", exact: true}); + await opener.click(); + const inspector = page.getByRole("dialog", {name: "Ask about selection", exact: true}); + await expect(inspector).toBeVisible(); + const question = page.getByRole("textbox", {name: "Question", exact: true}); + await expect(question).toBeFocused(); + await question.fill("Keep the source-bound coordinates."); + await expect(page.locator("#selected-context li")).toHaveText("\u{1f9ed}"); + const focusStates = []; + for (let index = 0; index < 10; index += 1) { + await page.keyboard.press("Tab"); + focusStates.push(await inspector.evaluate(element => ({documentFocused: document.hasFocus(), inside: element.contains(document.activeElement), body: document.activeElement === document.body}))); + } + // W3C H102 permits native modal focus in the dialog or browser chrome. + expect(focusStates.filter(state => state.documentFocused).length).toBeGreaterThanOrEqual(3); + for (const state of focusStates) expect(state.documentFocused ? state.inside : state.body).toBe(true); + await question.focus(); + const accessibility = await analyzeAxe(page); + expect(accessibility.violations).toEqual([]); + await page.keyboard.press("Escape"); + await expect(inspector).not.toBeVisible(); + await expect(opener).toBeFocused(); + await opener.click(); + await expect(question).toHaveValue("Keep the source-bound coordinates."); + await page.getByRole("button", {name: "Create handoff packet", exact: true}).click(); + await expect(page.locator("#handoff-status")).toHaveText("Handoff packet created."); + const packet = JSON.parse(await page.locator("#handoff-packet").textContent()); + expect(packet.annotations[0]).toMatchObject({exactQuote: "\u{1f9ed}", startCodePoint: 6, endCodePoint: 7}); + await page.setViewportSize({width: 1440, height: 900}); + await expect(page.locator("#workspace-navigation")).toBeVisible(); + await expect(inspector).toBeVisible(); + expect(await page.locator("dialog:modal").count()).toBe(0); + await expect(question).toHaveValue("Keep the source-bound coordinates."); + await page.setViewportSize({width: 390, height: 844}); + await expect(page.locator("dialog:modal")).toHaveCount(0); + await expect(page.locator("#workspace-navigation")).not.toBeVisible(); + await expect(inspector).not.toBeVisible(); + await page.getByRole("button", {name: "Diff", exact: true}).click(); + await expect(page.locator("body")).toHaveAttribute("data-state", "diff-unavailable"); + await expect(page.locator("#selected-context li")).toHaveCount(0); + await opener.click(); + await expect(question).toHaveValue("Keep the source-bound coordinates."); + await expect(page.locator("#handoff-packet")).toBeEmpty(); +}); + +test("mobile navigation and inspector never retain simultaneous modal authority", async ({lookupURL, axePage: page}) => { + await page.setViewportSize({width: 390, height: 844}); + await openWorkspace(page, lookupURL); + await page.getByRole("button", {name: "Toggle specification navigation", exact: true}).click(); + await expect(page.locator("dialog:modal")).toHaveAttribute("id", "workspace-navigation"); + // Exercise the transition owner even when an outside opener is natively inert. + await page.locator("#open-inspector").evaluate(button => button.click()); + await expect(page.locator("dialog:modal")).toHaveAttribute("id", "workspace-inspector"); + await expect(page.locator("#workspace-navigation")).not.toBeVisible(); + await expect(page.locator("#annotation-question")).toBeFocused(); + await page.locator("#open-navigation").evaluate(button => button.click()); + await expect(page.locator("dialog:modal")).toHaveAttribute("id", "workspace-navigation"); + await expect(page.locator("#workspace-inspector")).not.toBeVisible(); + await expect(page.getByRole("searchbox")).toBeFocused(); + const accessibility = await analyzeAxe(page); + expect(accessibility.violations).toEqual([]); + await page.keyboard.press("Escape"); + await expect(page.locator("dialog:modal")).toHaveCount(0); + await expect(page.locator("#open-navigation")).toBeFocused(); +}); diff --git a/tests/browser/workspace-request-lifecycle.spec.mjs b/tests/browser/workspace-request-lifecycle.spec.mjs new file mode 100644 index 0000000..4521d6f --- /dev/null +++ b/tests/browser/workspace-request-lifecycle.spec.mjs @@ -0,0 +1,59 @@ +import {expect} from "@playwright/test"; +import {lookupTest as test} from "./workspace-test-harness.mjs"; +import {openWorkspace} from "./workspace-navigation-harness.mjs"; + +for (const late of [{route: "navigation", status: 403}, {route: "navigation", status: 200}, {route: "requirements", status: 403}]) { + test(`superseded non-cooperative ${late.route} ${late.status} cannot change current content or request authority`, async ({lookupURL, page}) => { + await page.addInitScript(({path}) => { + const nativeFetch = globalThis.fetch.bind(globalThis); + globalThis.__lateWorkspaceResponses = 0; + globalThis.fetch = async (input, init = {}) => { + const url = new URL(typeof input === "string" ? input : input.url, location.href); + if (url.pathname !== path) return nativeFetch(input, init); + const query = typeof init.body === "string" ? JSON.parse(init.body).query : null; + const delayed = query?.searchText === "Root" || query?.parentNodeId === "spec.root"; + const response = await nativeFetch(input, {...init, signal: undefined}); + if (delayed) globalThis.__lateWorkspaceResponses += 1; + return response; + }; + }, {path: `/api/v1/${late.route}`}); + let release; + const barrier = new Promise(resolve => { release = resolve; }); + let markStarted; + const started = new Promise(resolve => { markStarted = resolve; }); + await page.route(`**/api/v1/${late.route}`, async route => { + const query = route.request().postDataJSON().query; + if (query.searchText !== "Root" && query.parentNodeId !== "spec.root") return route.continue(); + const response = late.status === 200 ? await route.fetch() : null; + markStarted(); + await barrier; + return response ? route.fulfill({response}) : route.fulfill({status: late.status, body: "private obsolete detail"}); + }); + try { + await openWorkspace(page, lookupURL); + await expect(page.locator("#workspace-content [data-requirement-id]").first()).toHaveAttribute("data-requirement-id", "REQ-A"); + if (late.route === "navigation") { + await page.getByRole("button", {name: "Expand Workspace root", exact: true}).click(); + await started; + await page.getByRole("button", {name: "Collapse Workspace root", exact: true}).click(); + } else { + await page.getByRole("searchbox").fill("Root"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await started; + } + await page.getByRole("searchbox").fill("REQ-B-129"); + await page.getByRole("button", {name: "Search requirements", exact: true}).click(); + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveAttribute("data-requirement-id", "REQ-B-129"); + await page.getByRole("button", {name: "Select invariant", exact: true}).click(); + release(); + await expect.poll(() => page.evaluate(() => globalThis.__lateWorkspaceResponses)).toBe(1); + await expect(page.locator("#workspace-content [data-requirement-id]")).toHaveAttribute("data-requirement-id", "REQ-B-129"); + await expect(page.locator("#selected-context li")).toHaveText("State \u{1f9ed} e\u0301 keeps source identity."); + await expect(page.getByRole("searchbox")).toBeEnabled(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.getByRole("button", {name: "Retry", exact: true})).toHaveCount(0); + await expect(page.getByRole("button", {name: "Expand Workspace root", exact: true})).toBeVisible(); + await expect(page.getByRole("button", {name: "Child contracts", exact: true})).toHaveCount(0); + } finally { release(); } + }); +} diff --git a/tests/browser/workspace-test-harness.mjs b/tests/browser/workspace-test-harness.mjs new file mode 100644 index 0000000..7b6b4af --- /dev/null +++ b/tests/browser/workspace-test-harness.mjs @@ -0,0 +1,44 @@ +import {spawn, execFileSync} from "node:child_process"; +import {mkdtempSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {expect, test as base} from "@playwright/test"; +import {startBrowserServer} from "../../scripts/browser-proof-execution.mjs"; + +export const test = base.extend({ + engineEvidence: [async ({browser, browserName, channel, connectOptions, launchOptions}, use, testInfo) => { + testInfo.annotations.push({type: "proofkit.browser-engine", description: browserName}); + testInfo.annotations.push({type: "proofkit.browser-version", description: browser.version()}); + expect(browserName).toBe(testInfo.project.name); + expect(channel).toBeUndefined(); + expect(connectOptions).toBeUndefined(); + expect(launchOptions.channel).toBeUndefined(); + expect(launchOptions.executablePath).toBeUndefined(); + await use(); + }, {auto: true}], +}); + +export const lookupTest = test.extend({ + lookupURL: [async ({}, use) => { await withFixture("--lookup", use); }, {scope: "worker"}], +}); + +export const pagingTest = test.extend({ + pagingURL: [async ({}, use) => { await withFixture("--paging", use); }, {scope: "worker"}], +}); + +export const capacityTest = test.extend({ + capacityURL: [async ({}, use) => { await withFixture("--capacity", use); }, {scope: "worker"}], +}); + +async function withFixture(selector, use) { + const directory = mkdtempSync(join(tmpdir(), "proofkit-browser-fixture-")); + try { + const binary = join(directory, "server"); + execFileSync("go", ["build", "-o", binary, "./internal/tools/browsertestserver"], {stdio: "pipe"}); + const server = await startBrowserServer(binary, { + spawnProcess: (path, args, options) => spawn(path, [selector, ...args], options), + }); + try { await use(server.url); } + finally { await server.stop(); } + } finally { rmSync(directory, {recursive: true, force: true}); } +} diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 3f71d65..7e66f0d 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -1,81 +1,9 @@ -import {expect, test} from "@playwright/test"; +import {expect} from "@playwright/test"; +import {test} from "./workspace-test-harness.mjs"; import {analyzeAxe, assertAxeTestComplete, initializeAxe} from "./axe-harness.mjs"; -const workspaceNavigationToken = "proofkit.workspace-navigation.scheduled"; - -function admittedWorkspaceURL(baseURL) { - if (typeof baseURL !== "string") throw new Error("Workspace base URL is unavailable"); - const url = new URL(baseURL); - if ( - url.protocol !== "http:" - || url.hostname !== "127.0.0.1" - || url.port === "" - || url.username !== "" - || url.password !== "" - || url.pathname !== "/" - || url.search !== "" - || url.hash !== "" - ) throw new Error("Workspace base URL is outside the admitted local origin"); - return url.href; -} - -function isWorkspaceNavigationResponse(candidate, workspaceURL, mainFrame) { - const request = candidate.request(); - return candidate.url() === workspaceURL - && request.isNavigationRequest() - && request.frame() === mainFrame; -} - -async function navigateWorkspace(page, workspaceURL, trigger, responseError) { - const controller = new AbortController(); - const mainFrame = page.mainFrame(); - const responsePromise = page.waitForResponse( - (candidate) => isWorkspaceNavigationResponse(candidate, workspaceURL, mainFrame), - {signal: controller.signal}, - ); - try { - const token = await trigger(workspaceNavigationToken); - if (token !== workspaceNavigationToken) { - throw new Error("Workspace navigation trigger token is invalid"); - } - const response = await responsePromise; - if (!response.ok()) throw new Error(responseError); - await expect( - page.getByRole("heading", {name: "browser.fixture.workspace", exact: true}), - ).toBeVisible(); - } catch (error) { - controller.abort(); - await responsePromise.catch(() => undefined); - throw error; - } -} - -async function openWorkspace(page, baseURL) { - const workspaceURL = admittedWorkspaceURL(baseURL); - await navigateWorkspace( - page, - workspaceURL, - (token) => page.evaluate(({target, value}) => { - window.setTimeout(() => window.location.assign(target), 0); - return value; - }, {target: workspaceURL, value: token}), - "Workspace navigation did not return a successful response", - ); -} - -async function reloadWorkspace(page, baseURL) { - const workspaceURL = admittedWorkspaceURL(baseURL); - await navigateWorkspace( - page, - workspaceURL, - (token) => page.evaluate((value) => { - window.setTimeout(() => window.location.reload(), 0); - return value; - }, token), - "Workspace reload did not return a successful response", - ); -} +import {admittedWorkspaceURL, isWorkspaceNavigationResponse, navigateWorkspace, openWorkspace, reloadWorkspace} from "./workspace-navigation-harness.mjs"; async function expectIdentityOrder(rows, expected) { await expect(rows).toHaveCount(expected.length); @@ -171,7 +99,7 @@ async function expectVisibleTable(table, caption, headers, rows) { await expect(table.locator("caption:visible, th:visible, td:visible")).toHaveCount(textCount); await expectAllCSS(textElements, { opacity: "1", - color: "rgb(23, 32, 51)", + color: "rgb(32, 37, 34)", "font-size": "16px", "font-size-adjust": "none", "-webkit-text-security": "none", @@ -187,16 +115,6 @@ async function expectVisibleTable(table, caption, headers, rows) { }); } -test.beforeEach(async ({browser, browserName, channel, connectOptions, launchOptions}, testInfo) => { - testInfo.annotations.push({type: "proofkit.browser-engine", description: browserName}); - testInfo.annotations.push({type: "proofkit.browser-version", description: browser.version()}); - expect(browserName).toBe(testInfo.project.name); - expect(channel).toBeUndefined(); - expect(connectOptions).toBeUndefined(); - expect(launchOptions.channel).toBeUndefined(); - expect(launchOptions.executablePath).toBeUndefined(); -}); - const axeTest = test.extend({ axePage: async ({page}, use) => { await initializeAxe(page); @@ -236,7 +154,7 @@ const workspaceStateMatrix = [ setup: async (page, baseURL) => { await page.route("**/api/v1/manifest", (route) => route.fulfill({status: 503, contentType: "application/json", body: '{"error":"secret-internal-detail"}'})); await openWorkspace(page, baseURL); - await expect(page.getByRole("alert")).toContainText("The admitted workspace could not be loaded."); + await expect(page.getByRole("alert")).toContainText("The workspace could not be reached. Try this request again."); await expect(page.getByRole("alert")).not.toContainText("secret-internal-detail"); }, packetState: "empty", @@ -365,7 +283,7 @@ const workspaceStateMatrix = [ setup: async (page, baseURL) => { await page.route("**/api/v1/requirements", (route) => route.fulfill({status: 500, contentType: "application/json", body: '{"error":"secret-view-detail"}'})); await openWorkspace(page, baseURL); - await expect(page.getByRole("alert")).toContainText("The admitted workspace view could not be loaded."); + await expect(page.getByRole("alert")).toContainText("The workspace could not be reached. Try this request again."); await expect(page.getByRole("alert")).not.toContainText("secret-view-detail"); }, packetState: "empty", @@ -612,7 +530,7 @@ for (const mismatch of [ await openWorkspace(page, baseURL); await expect(page.locator("body")).toHaveAttribute("data-state", "view-failed"); await expect(page.locator("#workspace-content")).toHaveAttribute("aria-busy", "false"); - await expect(page.getByRole("alert")).toHaveText("The admitted workspace view could not be loaded."); + await expect(page.getByRole("alert")).toHaveText("The admitted workspace is unavailable."); await expect(page.getByRole("alert")).not.toContainText(mismatch.value); }); } @@ -675,6 +593,7 @@ test("workspace renders admitted views and creates a keyboard-authorized handoff await expect(workspaceAuthority).toContainText("do not prove receipt freshness"); const requirementBoundary = page.getByLabel("Boundary for REQ-CONSUMER-001"); + await requirementBoundary.locator("summary").click(); await expect(requirementBoundary).toContainText("Owner: browser.fixture.owner"); await expect(requirementBoundary).toContainText("Claim level: blocking"); await expect(requirementBoundary).toContainText("Fixture requirements do not approve merge"); @@ -687,12 +606,11 @@ test("workspace renders admitted views and creates a keyboard-authorized handoff await specificationsView.focus(); await page.keyboard.press("Tab"); if (browserName === "webkit") { - const question = page.getByRole("textbox", {name: "Question"}); - if (await question.evaluate((element) => element === document.activeElement)) { + const authoritySummary = page.locator("#workspace-authority > summary"); + if (await authoritySummary.evaluate((element) => element === document.activeElement)) { // Playwright WebKit follows the macOS preference that Tab visits - // text-entry controls while Option-Tab includes all native controls. - await page.keyboard.press("Shift+Tab"); - await expect(question).not.toBeFocused(); + // selected native control kinds; Option-Tab includes all controls. + await expect(authoritySummary).toBeFocused(); await specificationsView.focus(); await page.keyboard.press("Alt+Tab"); await expect(diffView).toBeFocused(); @@ -891,7 +809,7 @@ test("workspace renders admitted views and creates a keyboard-authorized handoff display: /^(?:inline|block)$/, visibility: "visible", opacity: "1", - stroke: "rgb(23, 32, 51)", + stroke: "rgb(32, 37, 34)", "stroke-opacity": "1", "stroke-width": "1.5px", "stroke-dasharray": "none", @@ -942,9 +860,9 @@ test("workspace renders admitted views and creates a keyboard-authorized handoff display: /^(?:inline|block)$/, visibility: "visible", opacity: "1", - fill: "rgb(242, 244, 247)", + fill: "rgb(245, 246, 245)", "fill-opacity": "1", - stroke: "rgb(102, 112, 133)", + stroke: "rgb(119, 134, 125)", "stroke-opacity": "1", x: `${position.x}px`, y: `${position.y}px`, @@ -957,7 +875,7 @@ test("workspace renders admitted views and creates a keyboard-authorized handoff display: /^(?:inline|block)$/, visibility: "visible", opacity: "1", - fill: "rgb(23, 32, 51)", + fill: "rgb(32, 37, 34)", "fill-opacity": "1", "font-size": "13px", "font-size-adjust": "none", @@ -1163,8 +1081,8 @@ for (const unavailableView of [ await disableOptionalViews(page); await page.addInitScript(() => { const nativeFetch = globalThis.fetch.bind(globalThis); - globalThis.AbortController = class { - signal = {aborted: false, addEventListener() {}}; + const NativeAbortController = globalThis.AbortController; + globalThis.AbortController = class extends NativeAbortController { abort() {} }; globalThis.__proofkitLateResponse = {consumed: false, release: undefined, started: false}; @@ -1175,20 +1093,20 @@ for (const unavailableView of [ const {signal: _ignored, ...nonCooperativeInit} = init; const response = await nativeFetch(input, nonCooperativeInit); await new Promise((resolve) => { globalThis.__proofkitLateResponse.release = resolve; }); - return { - ok: response.ok, - status: response.status, - async json() { - const value = await response.json(); + for (const method of ["text", "json"]) { + const readBody = response[method].bind(response); + response[method] = async () => { + const value = await readBody(); setTimeout(() => { globalThis.__proofkitLateResponse.consumed = true; }, 0); return value; - }, - }; + }; + } + return response; }; }); await openWorkspace(page, baseURL); - await page.waitForFunction(() => globalThis.__proofkitLateResponse?.started === true); + await page.waitForFunction(() => typeof globalThis.__proofkitLateResponse?.release === "function"); await page.getByRole("button", {name: unavailableView.button}).click(); await expect(page.getByRole("heading", {name: unavailableView.heading})).toBeVisible(); await expect(page.locator("#workspace-content [role=status]")).toHaveAttribute("data-state", "unavailable"); @@ -1265,10 +1183,11 @@ async function assertHandoffPacketTabOrder(page, packetRegion) { expect(await packet.getAttribute("tabindex")).toBeNull(); expect(await packet.evaluate((element) => element.tabIndex)).toBe(-1); const submit = page.getByRole("button", {name: "Create handoff packet"}); - await submit.focus(); + const focusOwner = await submit.isDisabled() ? page.getByRole("textbox", {name: "Question"}) : submit; + await focusOwner.focus(); await page.keyboard.press("Tab"); await expect(packet).not.toBeFocused(); - await submit.focus(); + await focusOwner.focus(); await page.keyboard.press("Shift+Tab"); await expect(packet).not.toBeFocused(); } @@ -1306,7 +1225,9 @@ function withAxeOutcome(result, outcome, ruleId, target) { } async function assertReflow(page, row) { + const previousViewport = page.viewportSize(); await page.setViewportSize({width: 320, height: 800}); + if (row.heading === "Handoff packet") await page.getByRole("button", {name: "Toggle question inspector"}).click(); await assertWorkspaceState(page, row); const result = await page.evaluate(() => { const documentOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth; @@ -1321,14 +1242,33 @@ async function assertReflow(page, row) { scrollWidth: element.scrollWidth, tagName: element.tagName, })); - return {documentOverflow, internal}; + const viewTitles = [...document.querySelectorAll(".view-controls button")].map(button => { + const lines = new Set(); + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (!walker.currentNode.textContent.trim()) continue; + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + for (const rect of range.getClientRects()) { + if (rect.width > 0 && rect.height > 0) lines.add(rect.top); + } + } + return {title: button.textContent.trim(), lines: lines.size}; + }); + return {documentOverflow, internal, viewTitles}; }); expect(result.documentOverflow).toBeLessThanOrEqual(1); + expect(result.viewTitles).toEqual([ + {title: "Specifications", lines: 1}, {title: "Diff", lines: 1}, {title: "Traceability", lines: 1}, + ]); const unlabelledOverflow = result.internal.filter((viewport) => !["graph-viewport", "table-viewport"].includes(viewport.className) || viewport.role !== "region" || !viewport.label); expect(unlabelledOverflow).toEqual([]); + await page.setViewportSize(previousViewport); + await expect(page.locator("#workspace-inspector")).toBeVisible(); + await expect(page.locator("#workspace-navigation")).toBeVisible(); } async function assertRenderedContrast(page, row) { @@ -1371,7 +1311,7 @@ async function assertRenderedContrast(page, row) { const second = luminance(right); return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); }; - const controls = [document.querySelector("#submit-question"), document.querySelector("[data-view]:not([disabled])")] + const controls = [document.querySelector("#annotation-question"), document.querySelector("#submit-question:not([disabled])"), document.querySelector("[data-view]:not([disabled])")] .filter((element, index, values) => element instanceof HTMLElement && values.indexOf(element) === index); const values = []; for (const control of controls) {