diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6255d9d..b93a1cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: - name: Install Wails v3 CLI shell: bash - run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.103 + run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117 - name: Install golangci-lint v2 shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a4eba8..af01864 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: - name: Install Wails v3 CLI shell: bash - run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.103 + run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117 - name: Install frontend deps shell: bash diff --git a/go.mod b/go.mod index 0aef610..f729276 100644 --- a/go.mod +++ b/go.mod @@ -5,29 +5,29 @@ go 1.26.0 require ( github.com/google/uuid v1.6.0 github.com/pdfcpu/pdfcpu v0.12.1 - github.com/wailsapp/wails/v3 v3.0.0-alpha2.103 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 ) require ( github.com/adrg/xdg v0.5.3 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/coder/websocket v1.8.14 // indirect - github.com/ebitengine/purego v0.9.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/pkcs7 v0.2.2 // indirect github.com/hhrutter/tiff v1.0.3 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/wailsapp/wails/webview2 v1.0.24 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/image v0.40.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 09a5cf9..4c4e5a1 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,6 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -27,8 +25,11 @@ github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= @@ -41,18 +42,18 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20= github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.103 h1:gL7MzpN3SFw3rVjusmyNXds9PutZBk/M/AsSyppfN4c= -github.com/wailsapp/wails/v3 v3.0.0-alpha2.103/go.mod h1:GRW1qYl54Zi/w1mjCzDrMiy76g2BLfNlpqF640hgMf0= -github.com/wailsapp/wails/webview2 v1.0.24 h1:uULnjCSaRfMlU84mS3kjLgPsRosEOIusVK1nFOHZHzs= -github.com/wailsapp/wails/webview2 v1.0.24/go.mod h1:sdf+s0nAdxlzVWf9SCxC15XaxnQPJeY+uU1Ucn3jHQM= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= diff --git a/tests/14-2-wails-alpha2-117-upgrade/bindings_contract_test.go b/tests/14-2-wails-alpha2-117-upgrade/bindings_contract_test.go new file mode 100644 index 0000000..7dded34 --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/bindings_contract_test.go @@ -0,0 +1,85 @@ +// AC3.2, AC3.3: consumer-driven binding-presence contract. +// +// scenario 14.2-INTG-002 [P0] (risk R-14-08): the REGENERATED +// frontend/bindings/ must still export every PDFService method the frontend +// actually imports. This gates on the CONTRACT, NOT on the informational +// 29-method / 38-model counts (a legitimate refactor can move those; the +// brittle-count pin was dropped at 12-3 per project_struct_grep_tests_brittle). +// A `wails3 generate bindings -clean=true` that drops or renames a consumer +// method under the alpha2.117 bump fails loud here; an unrelated surface change +// does not. +// +// This is a STANDING regression net: it passes on the current tree (the binding +// already exports these) and re-passes after the bump + regen (AC3.2's +// zero-diff / reconciled case), or fails loud if the regen moved the wire. +package story_14_2_wails_alpha2_117_upgrade_test + +import ( + "strings" + "testing" +) + +// consumerBoundMethods is the explicit set of PDFService methods the frontend +// PRODUCTION code imports from the generated binding +// (frontend/bindings/.../pdfservice/pdfservice.js). A deliberate subset of the +// full receiver surface -- the consumer contract, not the whole API and not a +// count. Sourced from the non-test `import { ... } from +// '.../pdfservice/pdfservice.js'` sites (App.jsx, ObjectInfoPanel.tsx, +// DetailPanel.tsx, XRefTableView.tsx, CommandPalette.tsx, TreePanel.tsx, +// TabBar.tsx, useObjectIndex.ts, usePDFService.ts). If the dev adds/removes a +// frontend import in the same change, this list moves WITH the real consumer +// dependency -- which is exactly the contract we want, unlike a magic count that +// churns on every unrelated surface change. +var consumerBoundMethods = []string{ + "CloseDocument", + "ConsumePendingOpenFiles", + "GetObjectSource", + "GetObjectDetail", + "GetContentStream", + "GetImageData", + "GetReverseRefs", + "GetFontView", + "GetXRefTable", + "GetAncestorPath", + "GetChildren", + "GetObjectIndex", + "OpenFile", + "GetTreeRoot", + "OpenFileDialog", + "GoToPage", +} + +// bindingRelPath is the regenerated Wails JS binding for PDFService. +const bindingRelPath = "frontend/bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js" + +// Test_14_2_INTG_002_BindingsExportConsumerMethods [P0] AC3.2/AC3.3: the +// (re)generated binding exports every method the frontend actually invokes. +// The presence contract that replaces the count==N pin. A `-clean=true` regen +// under the alpha2.117 bump that drops or renames a consumer method fails loud +// here; an unrelated change to the full method surface does NOT. +func Test_14_2_INTG_002_BindingsExportConsumerMethods(t *testing.T) { + if !fileExists(t, bindingRelPath) { + t.Fatalf("[P0] 14.2-INTG-002: regenerated binding %s must exist (run `wails3 generate bindings -clean=true`, AC3.2 / Task 2.2)", bindingRelPath) + } + src := readSource(t, bindingRelPath) + for _, m := range consumerBoundMethods { + needle := "export function " + m + "(" + if !strings.Contains(src, needle) { + t.Errorf("[P0] 14.2-INTG-002: pdfservice.js must export consumer-bound method %q after regen -- the frontend imports it directly; a dropped/renamed binding silently breaks the UI (AC3.3 presence contract, NOT a count)", m) + } + } +} + +// Test_14_2_INTG_002_BindingsDoNotResurrectGetPlainTextFull [P1] AC3.2: a +// `-clean=true` regen must not re-introduce the GetPlainTextFull symbol that +// Story 10-1 removed. Guards against a stale / non-clean regen re-baselining a +// dead binding under the bump. +func Test_14_2_INTG_002_BindingsDoNotResurrectGetPlainTextFull(t *testing.T) { + if !fileExists(t, bindingRelPath) { + t.Skipf("[P1] 14.2-INTG-002: %s missing -- see Test_14_2_INTG_002_BindingsExportConsumerMethods", bindingRelPath) + } + src := readSource(t, bindingRelPath) + if strings.Contains(src, "GetPlainTextFull") { + t.Errorf("[P1] 14.2-INTG-002: pdfservice.js must NOT export GetPlainTextFull -- 10-1 removed the Go method; a stale (`-clean=false`) regen re-introduces a dead binding (AC3.2)") + } +} diff --git a/tests/14-2-wails-alpha2-117-upgrade/deferred_gates_test.go b/tests/14-2-wails-alpha2-117-upgrade/deferred_gates_test.go new file mode 100644 index 0000000..03106db --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/deferred_gates_test.go @@ -0,0 +1,56 @@ +// Deferred / non-unit-automatable gate scenarios, encoded as documented +// skipped-with-reason placeholders rather than fabricated automation. These +// exist so the story's full gate is TRACEABLE in the suite (every 14.2 scenario +// has a home) without pretending a headless Go test can close a check that needs +// the wails3 toolchain, a git working tree, or real desktop hardware. +// +// Per the ATDD directive: follow the test pyramid, do NOT create E2E/browser +// tests for criteria that need full desktop/WebView interaction, and encode the +// bindings zero-diff check as a documented integration check (not a grep). +package story_14_2_wails_alpha2_117_upgrade_test + +import "testing" + +// Test_14_2_GATE_001_ZeroDiffBindings [P0] AC3.2 (risk R-14-08), THE acceptance +// criterion. DEFERRED: this is a process/git assertion, not a pure unit test. +// The check is: after `wails3 generate bindings -clean=true`, `git diff +// --exit-code frontend/bindings/` is EMPTY (or a non-empty diff has been fully +// reconciled and re-passed against 14.2-INTG-002/003). It requires the wails3 +// CLI installed at the target pin and a clean git working tree -- neither +// available to a headless red-phase Go test. The Dev step runs it in CI/locally +// as a blocking gate and records the result in the Dev Agent Record; the +// presence contract (14.2-INTG-002) and wire-shape guard (14.2-INTG-003) are the +// automated nets that catch the specific drift a non-empty diff would carry. +func Test_14_2_GATE_001_ZeroDiffBindings(t *testing.T) { + t.Skip("[P0] 14.2-GATE-001: DEFERRED to the Dev/CI gate -- `wails3 generate bindings -clean=true` must yield an EMPTY `git diff frontend/bindings/` (or a reconciled diff re-passing 14.2-INTG-002/003). Needs the wails3 CLI at the target pin + a clean git tree; not automatable in a headless red-phase Go test. See 14.2-INTG-002 / 14.2-INTG-003 for the automated drift nets.") +} + +// Test_14_2_GATE_002_ThreeRunnerBuildSmoke [P1] AC3.4 (risk R-14-07). DEFERRED: +// the 3-runner (ubuntu/macos/windows) `wails3 build` smoke proves compile+link, +// NOT runtime. It runs in the CI matrix with the wails3 CLI installed at the +// target pin; it is not a Go unit test and is not reproducible here. +func Test_14_2_GATE_002_ThreeRunnerBuildSmoke(t *testing.T) { + t.Skip("[P1] 14.2-GATE-002: DEFERRED to the CI 3-runner matrix -- host `wails3 build` on ubuntu/macos/windows (compile+link only, not runtime). Needs the wails3 CLI at the target pin; not automatable in this Go module.") +} + +// Test_14_2_MANUAL_001_DesktopWebViewSmoke [P0] AC4 (risk R-14-07). DEFERRED +// HUMAN/HARDWARE GATE: the 5-step desktop smoke (launch/no-white-screen, load a +// real PDF round trip, exercise one method per binding category, one error path +// surfaces in the UI, clean quit) runs OUT-OF-BAND on real macOS + Windows +// hardware with a human observer. Headless CI cannot see the WebView. NO +// E2E/browser test is authored (Playwright cannot drive the native WebView). +// Evidence is captured as dated screenshots/recordings in the Dev Agent Record; +// the story does not reach `done` until that evidence is attached. +func Test_14_2_MANUAL_001_DesktopWebViewSmoke(t *testing.T) { + t.Skip("[P0] 14.2-MANUAL-001: DEFERRED human/hardware gate -- 5-step WebView smoke on real macOS + Windows, run out-of-band by a human, evidence in the Dev Agent Record (pending-human). Not automatable; no E2E authored per the test-pyramid directive.") +} + +// Test_14_2_MANUAL_002_GuiRegressionSweep [P1] AC9/AC10 (risk R-14-10). DEFERRED +// HUMAN/HARDWARE GATE folded into the same manual pass as 14.2-MANUAL-001: +// window placement per OS (Windows first-launch centering), splash init, About +// dialog populates, WebView renders without artifacts; plus the AC10 record-only +// yes/no on the two Epic-15 framework-gated GUI items. Human observation on real +// hardware; not automatable, nothing implemented against either GUI primitive. +func Test_14_2_MANUAL_002_GuiRegressionSweep(t *testing.T) { + t.Skip("[P1] 14.2-MANUAL-002: DEFERRED human/hardware gate -- GUI regression sweep + AC10 record-only unblock check, folded into the manual pass on real macOS + Windows. Not automatable; evidence/record in the Dev Agent Record.") +} diff --git a/tests/14-2-wails-alpha2-117-upgrade/go.mod b/tests/14-2-wails-alpha2-117-upgrade/go.mod new file mode 100644 index 0000000..79a2671 --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/go.mod @@ -0,0 +1,3 @@ +module story-14-2-wails-alpha2-117-upgrade-tests + +go 1.26 diff --git a/tests/14-2-wails-alpha2-117-upgrade/helpers_test.go b/tests/14-2-wails-alpha2-117-upgrade/helpers_test.go new file mode 100644 index 0000000..af1c728 --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/helpers_test.go @@ -0,0 +1,247 @@ +// Package story_14_2_wails_alpha2_117_upgrade_test provides RED-PHASE acceptance +// tests for Story 14.2: the governed Wails v3 version bump from the current pin +// v3.0.0-alpha2.103 to the committed target v3.0.0-alpha2.117 (Go module + +// wails3 CLI in lockstep), holding the @wailsio/runtime JS runtime at whatever +// upstream actually publishes (still alpha.79; no alpha2 runtime exists on npm). +// +// TDD RED PHASE: the version-pin tests MUST fail on the pre-bump tree (current +// pin alpha2.103) and pass after the Dev step lands the bump to the target per +// the story. The presence + wire-shape guards pass today and stand as the +// standing regression net across the bump. +// +// Test pyramid for this story (per the directive to favour API/integration over +// E2E, unit only where there is business logic): +// +// - The story introduces ZERO new business logic. The whole change is a +// platform-version pin move + binding regen + policy doc. There is no new +// function/hook/component to unit-test. +// - The decisive coverage (the live bindings round-trip inside the platform +// WebView, multi-WebView desktop smoke) is the native runtime layer. It +// needs real macOS + Windows hardware and a human observer and is, by the +// story's own design (AC4, a DEFERRED HUMAN/HARDWARE gate), MANUAL cross-OS +// smoke recorded in the Dev Agent Record. Playwright cannot drive it; no red +// E2E is authored (see deferred_gates_test.go for the documented skips). +// - So every automated test here is a Go structural / CLI-integration +// acceptance test in this independent module (mirrors +// tests/12-3-wails-alpha2-103-upgrade/). +// +// This module is an independent go.mod (the project convention: no `replace` +// link into the main module, which would drag the whole Wails tree into a test +// module). It reads the repo's static files (go.mod, the two CI workflows, +// frontend/package.json, the regenerated binding) and exercises the CLI, which +// is the executable expression of the SAME internal/pdfcore model.go structs. +// +// Run: cd tests/14-2-wails-alpha2-117-upgrade && go test -v -count=1 ./... +package story_14_2_wails_alpha2_117_upgrade_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" +) + +// alpha2Base offsets the alpha2.* scheme onto one monotonic ordinal line above +// every plain alpha.* number Wails has shipped, so alpha2.N always sorts after +// alpha.N (matching upstream ordering: alpha.102 < alpha2.103 < alpha2.117). +const alpha2Base = 1000 + +// currentBaselineOrdinal is the ordinal of the pre-bump pin v3.0.0-alpha2.103. +// Every Go-side touch-point (go.mod, ci.yml, release.yml) must move STRICTLY +// past this on a bump; the tree sat exactly at it when these tests were authored +// (RED phase), which is why the version-pin tests were RED before the bump landed. +const currentBaselineOrdinal = alpha2Base + 103 + +// targetOrdinal is the committed AC1/AC2 target v3.0.0-alpha2.117. The go.mod +// pin must reach this floor (>=), which encodes "the bump landed the committed +// target" while staying non-brittle to a defensibly-newer most-baked pick. +// Fallback rungs (AC7): rung 1 = alpha2.103 (current known-good), rung 2 = +// alpha.102 (deeper) -- both sit BELOW this floor, so a rollback correctly +// leaves this gate red (the story does not reach done on a rolled-back tree). +const targetOrdinal = alpha2Base + 117 + +// jsRuntimeCurrentAlpha is the current @wailsio/runtime pin alpha number. npm +// tops out at alpha.79 (AC5): the runtime is held to what upstream publishes, so +// the pin must stay a well-formed 3.0.0-alpha.N tag with N >= this and must NOT +// be rewritten to a phantom alpha2.* tag (none is published on npm). +const jsRuntimeCurrentAlpha = 79 + +// goWailsRe matches a Wails Go pin in either scheme and captures the variant +// digit (empty for plain `alpha`, "2" for `alpha2`) and the trailing number: +// +// v3.0.0-alpha.102 -> groups ("", "102") +// v3.0.0-alpha2.117 -> groups ("2", "117") +var goWailsRe = regexp.MustCompile(`v3\.0\.0-alpha(2)?\.(\d+)`) + +// jsRuntimeRe matches an @wailsio/runtime pin in either scheme (same shape as +// goWailsRe, sans the leading `v`). +var jsRuntimeRe = regexp.MustCompile(`3\.0\.0-alpha(2)?\.(\d+)`) + +// alphaOrdinal collapses the two version schemes into ONE monotonic ordinal so +// "strictly newer" comparisons work across the alpha2.103 -> alpha2.117 move. +// Returns -1 on no match. +func alphaOrdinal(re *regexp.Regexp, s string) int { + m := re.FindStringSubmatch(s) + if len(m) < 3 { + return -1 + } + n, err := strconv.Atoi(m[2]) + if err != nil { + return -1 + } + if m[1] == "2" { + return alpha2Base + n + } + return n +} + +// allAlphaOrdinals returns the ordinal of every Wails/runtime pin matched in s. +// Used to catch a workflow file that still carries the old pin alongside a new +// one (a half-done bump). +func allAlphaOrdinals(re *regexp.Regexp, s string) []int { + all := re.FindAllStringSubmatch(s, -1) + out := make([]int, 0, len(all)) + for _, m := range all { + if len(m) < 3 { + continue + } + n, err := strconv.Atoi(m[2]) + if err != nil { + continue + } + if m[1] == "2" { + out = append(out, alpha2Base+n) + } else { + out = append(out, n) + } + } + return out +} + +// projectRoot walks up from the working directory until it finds the project +// go.mod (module unidoc-pdf-debugger), and returns its absolute path. +func projectRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if data, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { + if strings.Contains(string(data), "module unidoc-pdf-debugger") { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not find project root (no go.mod with module unidoc-pdf-debugger)") + } + dir = parent + } +} + +// readSource reads a file relative to the project root. +func readSource(t *testing.T, relPath string) string { + t.Helper() + root := projectRoot(t) + content, err := os.ReadFile(filepath.Join(root, relPath)) + if err != nil { + t.Fatalf("cannot read %s: %v", relPath, err) + } + return string(content) +} + +// fileExists returns true when relPath exists under projectRoot. +func fileExists(t *testing.T, relPath string) bool { + t.Helper() + root := projectRoot(t) + _, err := os.Stat(filepath.Join(root, relPath)) + return err == nil +} + +// goWailsLine returns the `github.com/wailsapp/wails/v3 ` require line from +// go.mod, or "" if absent. +func goWailsLine(t *testing.T) string { + t.Helper() + src := readSource(t, "go.mod") + for _, l := range strings.Split(src, "\n") { + if strings.Contains(l, "github.com/wailsapp/wails/v3 ") { + return l + } + } + return "" +} + +// fmtOrdinal renders an ordinal back to a human-readable Wails tag for messages. +func fmtOrdinal(ord int) string { + if ord >= alpha2Base { + return fmt.Sprintf("alpha2.%d", ord-alpha2Base) + } + return fmt.Sprintf("alpha.%d", ord) +} + +// tagFromOrdinal renders an ordinal back to the bare tag form used in go.sum +// (`alpha.N` / `alpha2.N`), without the leading `v3.0.0-`. +func tagFromOrdinal(ord int) string { + if ord >= alpha2Base { + return "alpha2." + strconv.Itoa(ord-alpha2Base) + } + return "alpha." + strconv.Itoa(ord) +} + +// buildCLI compiles the pdfdebug CLI into a temp dir and returns its path. The +// CLI is the executable expression of the internal/pdfcore model.go structs, so +// its JSON output is the real-Marshal wire shape the wire-shape guard inspects +// without this module having to import (and thus replace-link) the main module. +func buildCLI(t *testing.T) string { + t.Helper() + root := projectRoot(t) + tmpDir := t.TempDir() + binName := "pdfdebug" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(tmpDir, binName) + cmd := exec.Command("go", "build", "-o", binPath, "./cmd/cli/") + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("failed to build CLI binary: %s\n%s", err, output) + } + return binPath +} + +// runCLI executes the CLI binary with args and returns stdout, stderr, exit code. +func runCLI(t *testing.T, binPath string, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.Command(binPath, args...) + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + exitCode = 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + t.Fatalf("failed to run CLI: %v", err) + } + } + return outBuf.String(), errBuf.String(), exitCode +} + +// mustParseJSONObject parses s as a single JSON object, failing on error. +func mustParseJSONObject(t *testing.T, s string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(s)), &m); err != nil { + t.Fatalf("failed to parse JSON object: %v\nraw: %s", err, s) + } + return m +} diff --git a/tests/14-2-wails-alpha2-117-upgrade/version_pins_test.go b/tests/14-2-wails-alpha2-117-upgrade/version_pins_test.go new file mode 100644 index 0000000..363f5a1 --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/version_pins_test.go @@ -0,0 +1,189 @@ +// AC1, AC2, AC3.1, AC5, AC8: the CLI==library version-pin invariant plus the +// runtime-honesty guard. +// +// scenario 14.2-INTG-001 [P0] (risk R-14-11): the wails3 CLI pin in BOTH +// .github/workflows/ci.yml AND .github/workflows/release.yml must EQUAL the +// go.mod library pin exactly, and go.mod must reach the committed target +// v3.0.0-alpha2.117. This mirrors tests/12-3 INTG-020/021 -- it reads the files +// statically and does NOT shell out to `wails3 --version` (the live +// go.mod==wails3 equality is confirmed only on the legs where the CLI is +// installed: the 3-runner build and the manual smoke, out of this module's +// scope). A mismatch means everything downstream tests a phantom -- stop. +// +// Authored RED (go.mod at alpha2.103, below the alpha2.117 floor); now GREEN +// after the committed bump and standing as the regression gate against drift. +package story_14_2_wails_alpha2_117_upgrade_test + +import ( + "encoding/json" + "strconv" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// AC1/AC2 -- go.mod library pin reaches the committed target +// --------------------------------------------------------------------------- + +// Test_14_2_INTG_001_GoModPinAtTarget [P0] AC1/AC2: go.mod's wails/v3 pin +// reaches the committed target alpha2.117. Authored RED (pin at alpha2.103, +// below the target floor); GREEN once the bump lands. The assertion is a floor (>= target), not +// an exact ==, so a defensibly-newer most-baked pick per AC1 still passes while +// the current pin and both fallback rungs (alpha2.103, alpha.102) stay red. +func Test_14_2_INTG_001_GoModPinAtTarget(t *testing.T) { + line := goWailsLine(t) + if line == "" { + t.Fatalf("[P0] 14.2-INTG-001: go.mod must declare a `github.com/wailsapp/wails/v3` require") + } + got := alphaOrdinal(goWailsRe, line) + if got < 0 { + t.Fatalf("[P0] 14.2-INTG-001: go.mod wails/v3 line %q must carry a v3.0.0-alpha.N or v3.0.0-alpha2.N tag", line) + } + if got < targetOrdinal { + t.Errorf("[P0] 14.2-INTG-001: go.mod wails/v3 pin %s has not reached the committed target %s (AC1/AC2). Current known-good fallback rung 1 = alpha2.103, deeper rung 2 = alpha.102 both sit below this floor.", + fmtOrdinal(got), fmtOrdinal(targetOrdinal)) + } +} + +// Test_14_2_INTG_001_GoSumCarriesNewPin [P0] AC2/AC8: go.sum carries an entry +// for the bumped wails/v3 pin (proves `go mod tidy` ran). Skips cleanly while +// go.mod is still below the target so the RED signal stays on +// Test_14_2_INTG_001_GoModPinAtTarget. +func Test_14_2_INTG_001_GoSumCarriesNewPin(t *testing.T) { + got := alphaOrdinal(goWailsRe, goWailsLine(t)) + if got < targetOrdinal { + t.Skipf("[P0] 14.2-INTG-001: skipped -- go.mod not bumped to target yet (see Test_14_2_INTG_001_GoModPinAtTarget)") + } + needle := "github.com/wailsapp/wails/v3 v3.0.0-" + tagFromOrdinal(got) + gosum := readSource(t, "go.sum") + if !strings.Contains(gosum, needle) { + t.Errorf("[P0] 14.2-INTG-001: go.sum must contain %q -- run `go mod tidy` after editing go.mod (AC2 / Task 2.1)", needle) + } +} + +// --------------------------------------------------------------------------- +// AC3.1 -- CLI==library: wails3 install pin parity in BOTH workflows +// --------------------------------------------------------------------------- + +// assertWorkflowPinMatchesGoMod is the shared body for the ci.yml / release.yml +// CLI-pin parity assertions (mirrors tests/12-3 INTG-020/021): the workflow must +// install wails3 with a v3.0.0 alpha pin, every alpha pin in the file must move +// strictly past the pre-bump baseline (alpha2.103), and every one must EQUAL the +// go.mod pin exactly (CLI == library). Authored RED (install line at alpha2.103, +// not strictly past the alpha2.103 baseline); GREEN once the workflows are bumped. +func assertWorkflowPinMatchesGoMod(t *testing.T, relPath, testID string) { + t.Helper() + src := readSource(t, relPath) + if !strings.Contains(src, "wails3@v3.0.0-alpha") { + t.Fatalf("[P0] %s: %s must install wails3 with a v3.0.0-alpha pin", testID, relPath) + } + got := allAlphaOrdinals(goWailsRe, src) + if len(got) == 0 { + t.Fatalf("[P0] %s: %s must reference a v3.0.0-alpha pin", testID, relPath) + } + goOrd := alphaOrdinal(goWailsRe, goWailsLine(t)) + for _, n := range got { + if n <= currentBaselineOrdinal { + t.Errorf("[P0] %s: %s carries %s -- a bump must move it strictly past the pre-bump baseline %s (AC3.1)", + testID, relPath, fmtOrdinal(n), fmtOrdinal(currentBaselineOrdinal)) + } + if goOrd > 0 && n != goOrd { + t.Errorf("[P0] %s: %s wails3 CLI pin %s must EQUAL the go.mod library pin %s exactly (CLI == library, AC3.1)", + testID, relPath, fmtOrdinal(n), fmtOrdinal(goOrd)) + } + } +} + +// Test_14_2_INTG_001_CiWorkflowPinParity [P0] AC3.1: ci.yml's wails3 CLI install +// pin is bumped and equals the go.mod library pin. +func Test_14_2_INTG_001_CiWorkflowPinParity(t *testing.T) { + assertWorkflowPinMatchesGoMod(t, ".github/workflows/ci.yml", "14.2-INTG-001") +} + +// Test_14_2_INTG_001_ReleaseWorkflowPinParity [P0] AC3.1: release.yml's wails3 +// CLI install pin is bumped and equals the go.mod library pin. +func Test_14_2_INTG_001_ReleaseWorkflowPinParity(t *testing.T) { + assertWorkflowPinMatchesGoMod(t, ".github/workflows/release.yml", "14.2-INTG-001") +} + +// Test_14_2_INTG_001_ReleaseExpectedFilesInvariant [P1] AC8 (regression net): +// release.yml retains the EXPECTED_FILES=6 publish invariant. A framework bump +// must not disturb the artifact-count contract. Passes today; stands as a net. +func Test_14_2_INTG_001_ReleaseExpectedFilesInvariant(t *testing.T) { + src := readSource(t, ".github/workflows/release.yml") + if !strings.Contains(src, "EXPECTED_FILES=6") { + t.Errorf("[P1] 14.2-INTG-001: release.yml must retain EXPECTED_FILES=6 invariant across the bump") + } +} + +// --------------------------------------------------------------------------- +// AC5/AC8 -- runtime held to what upstream publishes (no phantom alpha2) +// --------------------------------------------------------------------------- + +// Test_14_2_INTG_004_RuntimePinNoPhantomAlpha2 [P0] AC5/AC8: @wailsio/runtime is +// a well-formed 3.0.0-alpha.N tag with N >= the current pin (alpha.79). It must +// NOT be rewritten to a phantom alpha2.* tag -- npm publishes no alpha2 runtime, +// and Go-side currency (the alpha2.117 library bump) does NOT license runtime +// skew. This mirrors tests/12-3 INTG-030 and stays GREEN today (runtime is +// alpha.79); it HARD-FAILS if the bump hand-rolls an alpha2 runtime string. +// NOTE (AC8): if upstream DOES publish a matching alpha2.* runtime and it is +// adopted, this guard must be relaxed in lockstep or it goes red. +func Test_14_2_INTG_004_RuntimePinNoPhantomAlpha2(t *testing.T) { + src := readSource(t, "frontend/package.json") + var pkg map[string]any + if err := json.Unmarshal([]byte(src), &pkg); err != nil { + t.Fatalf("[P0] 14.2-INTG-004: package.json is not valid JSON: %v", err) + } + deps, ok := pkg["dependencies"].(map[string]any) + if !ok { + t.Fatalf("[P0] 14.2-INTG-004: package.json must declare a dependencies object") + } + raw, ok := deps["@wailsio/runtime"].(string) + if !ok { + t.Fatalf("[P0] 14.2-INTG-004: dependencies must declare @wailsio/runtime as a string pin") + } + m := jsRuntimeRe.FindStringSubmatch(raw) + if len(m) < 3 { + t.Fatalf("[P0] 14.2-INTG-004: @wailsio/runtime pin %q must carry a 3.0.0-alpha.N tag", raw) + } + // Phantom-alpha2 guard: no alpha2 runtime exists on npm (AC5 anti-pattern). + if m[1] == "2" { + t.Errorf("[P0] 14.2-INTG-004: @wailsio/runtime pin %q uses a phantom alpha2.* tag -- npm publishes no alpha2 runtime; Go-side currency does not license runtime skew (AC5). Relax this guard ONLY if upstream publishes a matching runtime and it is adopted (AC8).", raw) + } + n, _ := strconv.Atoi(m[2]) + if n < jsRuntimeCurrentAlpha { + t.Errorf("[P0] 14.2-INTG-004: @wailsio/runtime alpha.%d must be >= the current pin alpha.%d (AC5: never regress the runtime)", n, jsRuntimeCurrentAlpha) + } +} + +// Test_14_2_INTG_004_PackageLockRuntimeNotRegressed [P1] AC5/AC8: the lockfile's +// @wailsio/runtime resolution is not regressed below alpha.79 and carries no +// phantom alpha2.* tag. Mirrors tests/12-3 INTG-031. Passes today. +func Test_14_2_INTG_004_PackageLockRuntimeNotRegressed(t *testing.T) { + relPath := "frontend/package-lock.json" + if !fileExists(t, relPath) { + t.Fatalf("[P1] 14.2-INTG-004: %s must exist", relPath) + } + src := readSource(t, relPath) + sawRuntime := false + for _, line := range strings.Split(src, "\n") { + if !strings.Contains(line, "wailsio") { + continue + } + m := jsRuntimeRe.FindStringSubmatch(line) + if len(m) < 3 { + continue + } + sawRuntime = true + if m[1] == "2" { + t.Errorf("[P1] 14.2-INTG-004: package-lock.json resolves @wailsio/runtime to a phantom alpha2.* tag -- no such runtime is published (AC5)") + continue + } + if n, err := strconv.Atoi(m[2]); err == nil && n < jsRuntimeCurrentAlpha { + t.Errorf("[P1] 14.2-INTG-004: package-lock.json carries @wailsio/runtime alpha.%d, older than the current pin alpha.%d -- lockfile is corrupt or out of sync (AC5)", n, jsRuntimeCurrentAlpha) + } + } + if !sawRuntime { + t.Errorf("[P1] 14.2-INTG-004: package-lock.json must reference an @wailsio/runtime 3.0.0-alpha.N resolution") + } +} diff --git a/tests/14-2-wails-alpha2-117-upgrade/wire_shape_test.go b/tests/14-2-wails-alpha2-117-upgrade/wire_shape_test.go new file mode 100644 index 0000000..bd8628b --- /dev/null +++ b/tests/14-2-wails-alpha2-117-upgrade/wire_shape_test.go @@ -0,0 +1,114 @@ +// AC3.3: bindings wire-shape guard. +// +// scenario 14.2-INTG-003 [P0] (risk R-14-08): the classic silent regression on +// a Wails bump + binding regen is a JSON field getting re-tagged (e.g. +// `objectRef` -> `objectref`, or `nodeId` -> `nodeID`): TypeScript still +// compiles, but `payload.objectRef` arrives `undefined` in the UI. +// +// Option (a): a Go-side check that json.Marshals a POPULATED pdfcore payload and +// asserts the expected JSON keys/casing. This module is an independent go.mod +// (no `replace` link), so we exercise option (a)'s invariant via the CLI, the +// executable expression of the SAME internal/pdfcore model.go structs: `dump +// object` runs ObjectDetail (+ nested PropertyEntry/ValueEntry) through the real +// json.Marshal (mirrors tests/12-3 INTG-001/002 and tests/11-6-page-render-info). +// +// STANDING regression net: passes on the current tree and stands through the +// alpha2.117 bump. It is the AUTOMATED guard for the wire-shape risk; it does +// NOT replace the manual round-trip smoke (AC4, deferred human/hardware gate). +package story_14_2_wails_alpha2_117_upgrade_test + +import ( + "path/filepath" + "testing" +) + +// minimalPDFPath returns the absolute path to testdata/minimal.pdf under the +// project root. The CLI is exec'd with an arbitrary cwd, so the fixture must be +// addressed absolutely. +func minimalPDFPath(t *testing.T) string { + t.Helper() + return filepath.Join(projectRoot(t), "testdata", "minimal.pdf") +} + +// objectDetailWireKeys are the camelCase JSON keys the frontend reads off an +// ObjectDetail payload (internal/pdfcore/model.go ObjectDetail). DetailPanel.tsx +// destructures these; a struct-tag drift on any one is a silent undefined. +var objectDetailWireKeys = []string{ + "nodeId", + "objectRef", + "type", + "properties", +} + +// Test_14_2_INTG_003_ObjectDetailWireShape [P0] AC3.3: `dump object` emits the +// ObjectDetail payload with the expected camelCase top-level keys. Fails loud if +// a regen under the bump re-tags a field (e.g. nodeId -> nodeID). Passes on the +// current tree and stands as the standing regression net through the bump. +func Test_14_2_INTG_003_ObjectDetailWireShape(t *testing.T) { + bin := buildCLI(t) + // minimal.pdf object 1 is the /Catalog (a dict with /Pages + /Type). + stdout, stderr, code := runCLI(t, bin, "dump", "object", "--json", "--ref", "1 0 R", minimalPDFPath(t)) + if code != 0 { + t.Fatalf("[P0] 14.2-INTG-003: `dump object --ref \"1 0 R\" testdata/minimal.pdf` exited %d\nstderr: %s", code, stderr) + } + obj := mustParseJSONObject(t, stdout) + for _, key := range objectDetailWireKeys { + if _, ok := obj[key]; !ok { + t.Errorf("[P0] 14.2-INTG-003: ObjectDetail JSON missing key %q (case-sensitive) -- a bindings-regen struct-tag drift would silently break the frontend's payload destructuring (AC3.3)\nraw: %s", key, stdout) + } + } + // `type` must be the literal "dict" for the catalog -- pins value casing too. + if got, _ := obj["type"].(string); got != "dict" { + t.Errorf("[P0] 14.2-INTG-003: ObjectDetail.type for the catalog dict = %q, want \"dict\" (AC3.3: value contract, not just key presence)", got) + } +} + +// Test_14_2_INTG_003_NestedPropertyValueWireShape [P0] AC3.3: the nested +// PropertyEntry + ValueEntry shape on the same payload carries its camelCase +// keys. These are the deepest part of the wire contract the frontend walks +// (reference navigation reads value.refTarget), so a re-tag here is the +// hardest-to-spot drift. +func Test_14_2_INTG_003_NestedPropertyValueWireShape(t *testing.T) { + bin := buildCLI(t) + stdout, stderr, code := runCLI(t, bin, "dump", "object", "--json", "--ref", "1 0 R", minimalPDFPath(t)) + if code != 0 { + t.Fatalf("[P0] 14.2-INTG-003: dump object exited %d\nstderr: %s", code, stderr) + } + obj := mustParseJSONObject(t, stdout) + props, ok := obj["properties"].([]any) + if !ok || len(props) == 0 { + t.Fatalf("[P0] 14.2-INTG-003: ObjectDetail.properties must be a non-empty array for the catalog dict (got %T)\nraw: %s", obj["properties"], stdout) + } + // PropertyEntry: { "key": ..., "value": { ValueEntry } }. + var sawReference bool + for _, p := range props { + entry, ok := p.(map[string]any) + if !ok { + t.Fatalf("[P0] 14.2-INTG-003: each properties element must be an object (PropertyEntry)\nraw: %s", stdout) + } + if _, ok := entry["key"]; !ok { + t.Errorf("[P0] 14.2-INTG-003: PropertyEntry missing key \"key\" -- regen re-tag (AC3.3)") + } + val, ok := entry["value"].(map[string]any) + if !ok { + t.Errorf("[P0] 14.2-INTG-003: PropertyEntry missing object \"value\" (ValueEntry) -- regen re-tag (AC3.3)") + continue + } + // ValueEntry load-bearing keys: type, display, raw, refTarget. + for _, vk := range []string{"type", "display", "raw", "refTarget"} { + if _, ok := val[vk]; !ok { + t.Errorf("[P0] 14.2-INTG-003: ValueEntry missing key %q (case-sensitive) -- the frontend reads value.refTarget for ref navigation; a re-tag is a silent break (AC3.3)", vk) + } + } + // The /Pages entry is a reference: pin that refTarget is the node-id form. + if vt, _ := val["type"].(string); vt == "reference" { + sawReference = true + if rt, _ := val["refTarget"].(string); rt == "" { + t.Errorf("[P0] 14.2-INTG-003: reference ValueEntry must carry a non-empty refTarget node id (AC3.3)") + } + } + } + if !sawReference { + t.Errorf("[P0] 14.2-INTG-003: catalog properties must include the /Pages reference ValueEntry (the wire-shape fixture relies on it)\nraw: %s", stdout) + } +}