| 1 | #!/usr/bin/env bash |
| 2 | # The only CI this repo has. Everything a branch must pass before it can |
| 3 | # squash-merge, in one script so CI and what you run locally are one |
| 4 | # definition rather than two that drift. |
| 5 | set -euo pipefail |
| 6 | |
| 7 | # rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd |
| 8 | # user unit and is not on the PATH of a login shell when rustup was run |
| 9 | # with --no-modify-path. Source it here rather than depending on whoever |
| 10 | # configured the runner having got it right — a gate that only works on one |
| 11 | # machine is not a gate. |
| 12 | [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" |
| 13 | |
| 14 | for tool in cargo rustc rustfmt go node npm; do |
| 15 | command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; } |
| 16 | done |
| 17 | rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ |
| 18 | || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; } |
| 19 | |
| 20 | export CGO_ENABLED=0 |
| 21 | export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git" |
| 22 | |
| 23 | # The runner clones a fresh worktree per branch, so fixtures/node_modules |
| 24 | # (gitignored, like any node_modules) never carries over from a previous |
| 25 | # run. npm ci reads fixtures/package-lock.json rather than resolving from |
| 26 | # fixtures/package.json, so the installed yjs is exactly the pinned 13.6.27 |
| 27 | # — no silent drift from whatever a bare `npm install` would pick. |
| 28 | echo "=== fixtures: node dependencies" |
| 29 | (cd fixtures && npm ci) |
| 30 | |
| 31 | # save-queue.mjs is the editor's retry/drop logic, factored out so it |
| 32 | # runs under plain Node with no DOM/browser needed (fixtures/ |
| 33 | # save-queue.mjs's own doc comment). This is fix round 1's regression |
| 34 | # test for F1 — a permanently-rejected batch used to retry forever and |
| 35 | # absorb every later edit, losing all of it silently — and it belongs |
| 36 | # in the gate, not just something run by hand once, or a future |
| 37 | # regression here goes uncaught the same way F1 itself did. |
| 38 | echo "=== fixtures: the save queue's retry/drop logic (F1 regression)" |
| 39 | (cd fixtures && node --test save-queue.test.mjs) |
| 40 | |
| 41 | # fix round 2's blocking finding: a resync that failed once used to |
| 42 | # give up forever, silently recreating F1's own data loss for the |
| 43 | # rest of the session (resync-controller.mjs's own doc comment has |
| 44 | # the full mechanism). Same reasoning as the F1 test above — this |
| 45 | # belongs in the gate, not just something run by hand once. |
| 46 | echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regression)" |
| 47 | (cd fixtures && node --test resync-controller.test.mjs) |
| 48 | |
| 49 | # live-updates.mjs owns the cross-tab cursor and the stream's |
| 50 | # connection policy, and two of its tests are regressions for defects |
| 51 | # caught in review before the code existed: a cursor that advances |
| 52 | # over a gap skips rows permanently, and an idle-close whose only |
| 53 | # reopen triggers are visibility/focus/edit leaves a tab that is |
| 54 | # merely being READ permanently stale. Both fail silently in a |
| 55 | # browser — the document still saves, it just stops learning — so |
| 56 | # they belong in the gate rather than in a manual check nobody |
| 57 | # repeats. |
| 58 | echo "=== fixtures: the live-update cursor and connection policy" |
| 59 | (cd fixtures && node --test live-updates.test.mjs) |
| 60 | |
| 61 | # fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no |
| 62 | # banners, no timestamps, no absolute paths baked into the output, and |
| 63 | # esbuild's actual work happens in a pinned native binary (0.28.2), |
| 64 | # not interpreted JS, so the Node version running it does not enter |
| 65 | # into the result either. Five consecutive runs on this one runner |
| 66 | # produce identical sha256, which is all a freshness check ever |
| 67 | # needs — a weaker bar than cross-machine reproducibility, and |
| 68 | # already met. Rebuilding here and diffing what's committed mirrors |
| 69 | # "the embedded wasm must match a fresh build" below, for the same |
| 70 | # reason that check is safe rather than randomly red. |
| 71 | # align.mjs is the editor's half of paragraph alignment — the schema |
| 72 | # attribute, the command and the active-state reader — factored out of |
| 73 | # editor-entry.js and toolbar.mjs so it runs under plain Node with no |
| 74 | # DOM, exactly like the three modules above. It belongs in the gate for |
| 75 | # the reason its own tests spell out: the class names and attribute |
| 76 | # values in it are the same vocabulary as core/src/fragment.rs's |
| 77 | # ALIGNMENTS and internal/export/pdf.go's alignOf, which are separate |
| 78 | # binaries that cannot import it, and both of the mistakes it guards |
| 79 | # (an alignment nobody chose written into the document, a command that |
| 80 | # edits when it was only being asked a question) fail silently in a |
| 81 | # browser. |
| 82 | echo "=== fixtures: paragraph alignment in the editor" |
| 83 | (cd fixtures && node --test align.test.mjs) |
| 84 | |
| 85 | # fonts.mjs is the font vocabulary — the three faces, their marks, and |
| 86 | # which of them an unmarked run is in. That last one is the reason this |
| 87 | # is in the gate: the default face is stated in four places that cannot |
| 88 | # import each other (this module, views/show.html's [docs-text] rule, |
| 89 | # internal/export/pdf.go's styleFor and internal/fonts' Default), and a |
| 90 | # toolbar that disagrees with the other three fails completely silently |
| 91 | # — the control names a face the text is not in, and choosing that face |
| 92 | # does nothing because it is already what the run has no mark for. |
| 93 | # fonts.mjs also runs a load-time guard that only executes because this |
| 94 | # test imports it, the same arrangement align.test.mjs has with |
| 95 | # toolbar.mjs's. |
| 96 | echo "=== fixtures: the font vocabulary and its default" |
| 97 | (cd fixtures && node --test fonts.test.mjs) |
| 98 | |
| 99 | # paste.mjs decides what a blank line pasted out of Google Docs means. |
| 100 | # It is in the gate because the mistake it is one edit away from is |
| 101 | # invisible until someone pastes: turning EVERY hard_break into a |
| 102 | # paragraph break would destroy Shift+Enter, which editor-entry.js binds |
| 103 | # and core/src/fragment.rs renders, and it would do so only on the paste |
| 104 | # path — which had no test at all before this file. Like the four above |
| 105 | # it is DOM-free: transformPasted takes a Slice and returns a Slice. |
| 106 | echo "=== fixtures: what a pasted blank line means" |
| 107 | (cd fixtures && node --test paste.test.mjs) |
| 108 | |
| 109 | # indent.mjs is the second block attribute's vocabulary — the value is a |
| 110 | # NUMBER where alignment's is a string, so where align.test.mjs above |
| 111 | # checks that a closed table is consulted, this checks a clamp. The |
| 112 | # cases that matter are the ones no editor produces and |
| 113 | # POST /d/{sqid}/updates accepts anyway: a level past the maximum, a |
| 114 | # zero, a value that is not a number. core/src/fragment.rs holds the |
| 115 | # same three in Rust, independently, so a document cannot indent one |
| 116 | # distance on screen and another on paper. |
| 117 | echo "=== fixtures: the indent vocabulary and its clamp" |
| 118 | (cd fixtures && node --test indent.test.mjs) |
| 119 | |
| 120 | # sizes.mjs is the point ladder, and importing it here is what makes its |
| 121 | # own module-load guard run in the gate — the same arrangement align and |
| 122 | # fonts already have, for the reason their comments give: a guard that |
| 123 | # only executes when a browser mounts the editor is dead code in CI, so |
| 124 | # a drifted vocabulary would pass everything and then throw for every |
| 125 | # user. The legacy four-step marks are the part most worth holding: they |
| 126 | # are no longer offered and must go on being understood forever, because |
| 127 | # documents in the wild carry them. |
| 128 | echo "=== fixtures: the point ladder, and the four-step scale it replaced" |
| 129 | (cd fixtures && node --test sizes.test.mjs) |
| 130 | |
| 131 | # autoformat.mjs turns "- " into a bullet, and the Backspace that takes |
| 132 | # it back is the whole reason the feature is tolerable rather than |
| 133 | # infuriating. Both halves are in the gate because both are invisible |
| 134 | # until someone types: a rule that fires where it should not, and a |
| 135 | # revert that does not give back the characters, each look like a broken |
| 136 | # editor and neither shows up in any Go test. DOM-free — ProseMirror's |
| 137 | # state, model and inputrules packages are all headless, which matters |
| 138 | # because Chromium will not start in this project's sandbox, so a rule |
| 139 | # tested only through the browser drive is tested nowhere most of the |
| 140 | # time. |
| 141 | echo "=== fixtures: the autoformat rules, and the Backspace that undoes one" |
| 142 | (cd fixtures && node --test autoformat.test.mjs) |
| 143 | |
| 144 | # A word count is a number people quote to each other, so being quietly |
| 145 | # wrong is worse here than in most places. The cases that matter are the |
| 146 | # ones whitespace-splitting gets wrong — punctuation, and the languages |
| 147 | # that put no space between words at all, where splitting on whitespace |
| 148 | # reports 1 for a whole sentence. |
| 149 | echo "=== fixtures: counting words, including where there are no spaces" |
| 150 | (cd fixtures && node --test wordcount.test.mjs) |
| 151 | |
| 152 | # menu.mjs's enabling logic, against a forty-line fake DOM. |
| 153 | # |
| 154 | # In the gate because its absence already shipped a bug: sync read the |
| 155 | # module constant where it should have read the merged table, so every |
| 156 | # command the caller passed in — paged, infinite, word count, the |
| 157 | # shortcuts sheet — was disabled on every update and could never be |
| 158 | # clicked. Three deliverables unreachable, and nothing saw it: the Go |
| 159 | # tests check the model and the markup, and the gate cannot open a |
| 160 | # browser. The first test in this file fails against that exact edit. |
| 161 | echo "=== fixtures: which menu items the editor enables" |
| 162 | (cd fixtures && node --test menu.test.mjs) |
| 163 | |
| 164 | # Both bars of the editor's chrome, against the same kind of recording |
| 165 | # fake DOM. One plugin drives them in the browser (the toolbar's, with |
| 166 | # the menu on its onSync) and this drives them the same way. |
| 167 | # |
| 168 | # In the gate because its absence shipped a visible flash and would ship |
| 169 | # it again. Both bars sync on every ProseMirror state update, and both |
| 170 | # used to rewrite every control's state unconditionally; setAttribute |
| 171 | # with the value already there still invalidates style and still records |
| 172 | # a mutation, and so does setting a reflected boolean property to the |
| 173 | # value it already holds. So the chrome repainted once per keystroke, |
| 174 | # and a tab getting its frames back paid for the whole backlog in one |
| 175 | # visible frame — 23 mutations measured on reveal, 21 of them redundant |
| 176 | # attribute writes. |
| 177 | # |
| 178 | # Held against the source as well as against the controls that exist |
| 179 | # today, because the failure mode is a control added later: the count |
| 180 | # went from 12 to 23 across the toolbar redesign, and then menu.mjs, |
| 181 | # written afterwards by a different session, reproduced the whole defect |
| 182 | # from scratch. Five of the 23 were its. |
| 183 | echo "=== fixtures: the editor's chrome writes only what changed" |
| 184 | (cd fixtures && node --test toolbar.test.mjs) |
| 185 | |
| 186 | echo "=== the editor bundle must match a fresh build" |
| 187 | (cd fixtures && node build-bundles.mjs) |
| 188 | if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then |
| 189 | echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2 |
| 190 | # The rebuild above just mutated a committed directory as a side |
| 191 | # effect of checking it. Restore it to what's actually committed — |
| 192 | # tracked changes via checkout, any new hashed file the rebuild |
| 193 | # wrote via clean — so the only trace of this failure is the |
| 194 | # message and exit code above, not a stray dirty file a later |
| 195 | # command (or a re-run of this same script) trips over, or a |
| 196 | # rebuilt-to-fresh tree that would silently pass a second run |
| 197 | # without anyone having committed anything. |
| 198 | git checkout -- internal/docs/static/ |
| 199 | git clean -fd -- internal/docs/static/ >/dev/null |
| 200 | exit 1 |
| 201 | fi |
| 202 | |
| 203 | echo "=== rust: fmt" |
| 204 | cargo fmt --manifest-path core/Cargo.toml --check |
| 205 | |
| 206 | # build-wasm.sh is the ONLY clippy invocation in this gate (it runs |
| 207 | # --all-targets -D warnings -D clippy::disallowed_methods) and it runs |
| 208 | # BEFORE cargo test, for two reasons that used to be two separate bugs: |
| 209 | # |
| 210 | # 1. A second, looser clippy invocation here previously let a banned |
| 211 | # method planted in a test file sail through this fast check and get |
| 212 | # caught only ~40s later, inside build-wasm.sh's own clippy run -- |
| 213 | # defeating the point of failing fast. One invocation, no drift. |
| 214 | # 2. core/tests/no_host_imports.rs asserts zero wasm imports by reading |
| 215 | # the wasm32 artifact off disk; it only exists after this line. Running |
| 216 | # cargo test first made the assertion's own presence into a race — |
| 217 | # it silently reported PASS-via-SKIP unless some other test happened |
| 218 | # to build the artifact as a side effect first. Building here removes |
| 219 | # the race instead of hoping to win it. |
| 220 | echo "=== wasm: clippy, then the artifact the host embeds" |
| 221 | ./scripts/build-wasm.sh |
| 222 | |
| 223 | echo "=== rust: build, test" |
| 224 | # DOCS_REQUIRE_WASM=1 turns core/tests/no_host_imports.rs's "artifact |
| 225 | # missing" branch into a failure instead of a skip. It is always set here |
| 226 | # because the wasm artifact was just built above; unset, a bare local |
| 227 | # `cargo test` still skips gracefully on a fresh clone. |
| 228 | export DOCS_REQUIRE_WASM=1 |
| 229 | cargo build --release --manifest-path core/Cargo.toml |
| 230 | cargo test --manifest-path core/Cargo.toml |
| 231 | |
| 232 | echo "=== the embedded wasm must match a fresh build" |
| 233 | if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then |
| 234 | echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2 |
| 235 | exit 1 |
| 236 | fi |
| 237 | |
| 238 | echo "=== wire compatibility, both directions" |
| 239 | ./scripts/wire-gate.sh |
| 240 | |
| 241 | echo "=== go: vet, test, gofmt" |
| 242 | go vet ./... |
| 243 | # The browser checks (internal/docs/*_browser_test.go) need a Chromium, so |
| 244 | # the gate does not run them; it vets them, so a test behind a tag that |
| 245 | # nothing compiles cannot rot silently. |
| 246 | go vet -tags browser ./internal/docs/ |
| 247 | go test ./... -count=1 |
| 248 | test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; } |
| 249 | |
| 250 | echo "=== go: build ./... (the app, not just the packages tests import)" |
| 251 | # go vet/go test above already type-check every package they touch, |
| 252 | # but neither one proves cmd/docs itself links into a runnable binary |
| 253 | # under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) — |
| 254 | # a plain build is the only step that actually does. CGO_ENABLED is |
| 255 | # already exported above; stated again here so this line reads |
| 256 | # correctly on its own. |
| 257 | CGO_ENABLED=0 go build ./... |
| 258 | |
| 259 | # There used to be a further step here: build cmd/docs with -tags |
| 260 | # release and assert the result refuses to start with -dev-auth on |
| 261 | # its command line, proving a release binary had no code path that |
| 262 | # could ever turn the development-only stand-in identity on |
| 263 | # (cmd/docs/devauth_flag_release.go). Fix round 1 on Task 1 deleted |
| 264 | # that identity outright — internal/docs/devauth.go had zero readers |
| 265 | # once rastrillo/auth's RequireSession started guarding every |
| 266 | # document route unconditionally, with no flag anywhere capable of |
| 267 | # disabling it — so the `release` build tag it was the only user of |
| 268 | # is gone too, and there is no longer a release/non-release |
| 269 | # distinction for this gate to hold apart. The property that check |
| 270 | # guarded (an unauthenticated binary reachable at a real URL) is now |
| 271 | # covered a different way: go test above runs |
| 272 | # TestNewRefusesMissingInstanceKeyOutsideDevelopment and |
| 273 | # TestNewRefusesMissingMailOutsideDevelopment (internal/docs/auth_test.go), |
| 274 | # proving Env other than "development" still fails closed on its own |
| 275 | # fail-closed configuration, and TestSignInRequiredForEverything |
| 276 | # proves every document route — GET and POST alike — refuses a |
| 277 | # signed-out request. A stray "-dev-auth" on any build's command line |
| 278 | # today reaches rastrillo.Resolve's own flag.FlagSet undefined, and is |
| 279 | # refused exactly like any other unrecognized flag. |
| 280 | |
| 281 | echo "gate: ok" |
| 282 | |