fix: the grid's event handlers were one level too deep to ever fire
Jspreadsheet v5 takes onchange, onselection and oneditionstart on the ROOT config, beside parseFormulas — not inside a worksheet. Nested one level too deep they are accepted in silence and never called: the grid rendered, the console stayed clean, every Go test passed, and not one edit was ever sent to the server. Two real browser tabs found it in about a minute. The same run found rst-sr-only written as an attribute. It is one of AGENTS.md's seven class-only utilities, so it has no attribute twin and matched nothing — a screen-reader label rendered in the middle of the toolbar. That one is now a guard test, verified to fail on the exact markup that caused it: AGENTS.md says a rule you rely on remembering should be a test instead. The drive script is committed as a manual gate rather than folded into `make test`, because it needs a browser and a running instance and CI has neither. Its header says what it caught, so the next person knows why it is worth running. Verified: two tabs on one sheet, type 6 and =A1*7 in the first, and the second shows 6 and 42 without a reload. Its formula bar shows =A1*7 for the cell displaying 42.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
6 files changed,
+315
−29
internal/guardstest/markup_test.go+114 −0internal/sheets/grid.go+9 −6internal/sheets/static/grid.js+29 −21internal/sheets/static/sheets.css+10 −1internal/sheets/templates/sheet.html+1 −1tools/drive/drive.mjs+152 −0
CI passed — run details
diff --git a/internal/guardstest/markup_test.go b/internal/guardstest/markup_test.go| new file mode 100644 |
| index 0000000..c4bf893 |
| --- /dev/null |
| +++ b/internal/guardstest/markup_test.go |
| @@ -0,0 +1,114 @@ |
| +package guards |
| + |
| +import ( |
| + "io/fs" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// classOnlyUtilities are the seven rastrillo names that are classes by |
| +// design, listed in AGENTS.md's Frontend section: "An attribute says |
| +// what a thing *is*; a class says what is being applied to it." |
| +// |
| +// They have no attribute twin in tokens.css, so writing one as an |
| +// attribute produces markup that matches nothing — and fails silently. |
| +// That is exactly what happened here: `rst-sr-only` was written as an |
| +// attribute on the formula bar's label, and the label rendered visibly |
| +// in the middle of the toolbar. Nothing errored; it just looked wrong, |
| +// and only a screenshot caught it. Hence this test. |
| +var classOnlyUtilities = []string{ |
| + "rst-grow", "rst-mono", "rst-sr-only", "rst-m-hide", |
| + "rst-danger", "rst-nm", "rst-cell-mut", |
| +} |
| + |
| +// TestUtilitiesAreNeverWrittenAsAttributes walks the templates for a |
| +// class-only name used in attribute position. |
| +// |
| +// The match is deliberately narrow: `rst-sr-only` immediately after a |
| +// `<`-tag's whitespace and followed by `>`, `=`, or more whitespace. |
| +// `class="rst-sr-only"` does not match, because there the name is |
| +// inside a quoted value. |
| +func TestUtilitiesAreNeverWrittenAsAttributes(t *testing.T) { |
| + root := repoRoot(t) |
| + files := templateFiles(t, root) |
| + if len(files) == 0 { |
| + t.Fatal("guards: found no templates; this test is not checking anything") |
| + } |
| + |
| + for _, name := range classOnlyUtilities { |
| + // The name in attribute position: preceded by whitespace, and |
| + // followed by whitespace, '>', '/' or '=' — never by a quote, |
| + // which is what a class value looks like. |
| + re := regexp.MustCompile(`\s` + regexp.QuoteMeta(name) + `[\s>/=]`) |
| + for _, rel := range files { |
| + body, err := os.ReadFile(filepath.Join(root, rel)) |
| + if err != nil { |
| + t.Fatalf("guards: read %s: %v", rel, err) |
| + } |
| + for _, line := range strings.Split(string(body), "\n") { |
| + // A class list is the correct spelling and must not trip |
| + // this: only look at the parts of the line outside quotes. |
| + if re.MatchString(stripQuoted(line)) { |
| + t.Errorf("%s: %s is written as an attribute; it is one of the seven "+ |
| + "class-only utilities (AGENTS.md Frontend) and has no attribute "+ |
| + "twin, so it matches nothing:\n %s", |
| + rel, name, strings.TrimSpace(line)) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// stripQuoted blanks the contents of double- and single-quoted runs, so |
| +// a name inside class="..." is invisible to the attribute check while |
| +// the same name written bare is not. |
| +func stripQuoted(line string) string { |
| + var b strings.Builder |
| + var quote byte |
| + for i := 0; i < len(line); i++ { |
| + c := line[i] |
| + switch { |
| + case quote != 0: |
| + if c == quote { |
| + quote = 0 |
| + b.WriteByte(c) |
| + } else { |
| + b.WriteByte(' ') |
| + } |
| + case c == '"' || c == '\'': |
| + quote = c |
| + b.WriteByte(c) |
| + default: |
| + b.WriteByte(c) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// templateFiles returns every .html file under the repo. |
| +func templateFiles(t *testing.T, root string) []string { |
| + t.Helper() |
| + var out []string |
| + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules" { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if strings.HasSuffix(d.Name(), ".html") { |
| + out = append(out, relPath(t, root, path)) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("guards: walk: %v", err) |
| + } |
| + return out |
| +} |
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go| index 21a1376..082a445 100644 |
| --- a/internal/sheets/grid.go |
| +++ b/internal/sheets/grid.go |
| @@ -41,12 +41,15 @@ type gridCell struct { |
| // own edit arriving over the stream. Without it, the tab you typed in |
| // repaints the cell you are still editing. |
| type gridResponse struct { |
| - Version uint64 `json:"version"` |
| - Rows int `json:"rows"` |
| - Cols int `json:"cols"` |
| - Cells []gridCell `json:"cells"` |
| - Resync bool `json:"resync,omitempty"` |
| - Client string `json:"client,omitempty"` |
| + Version uint64 `json:"version"` |
| + // omitempty: a write response describes changed cells, not a |
| + // window, and "rows":0,"cols":0 on every edit is a claim about a |
| + // window nobody asked for. |
| + Rows int `json:"rows,omitempty"` |
| + Cols int `json:"cols,omitempty"` |
| + Cells []gridCell `json:"cells"` |
| + Resync bool `json:"resync,omitempty"` |
| + Client string `json:"client,omitempty"` |
| } |
| // display turns a computed value into the string the grid paints. |
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js| index 3406dda..d2c182b 100644 |
| --- a/internal/sheets/static/grid.js |
| +++ b/internal/sheets/static/grid.js |
| @@ -126,6 +126,34 @@ |
| var sheet = jspreadsheet(surface.querySelector('[data-sheet-grid]'), { |
| /* Our engine computes; the client paints. See the file comment. */ |
| parseFormulas: false, |
| + |
| + /* Every handler below is a SPREADSHEET-level option, not a |
| + * worksheet one. Nested inside worksheets[0] they are accepted |
| + * without complaint and never called — the grid renders, the |
| + * console stays clean, and no edit is ever sent anywhere. That |
| + * is how they were first written here, and only driving two real |
| + * browser tabs found it. */ |
| + onchange: function (instance, cell, c, r, value) { |
| + if (applying) return; |
| + write(parseInt(r, 10), parseInt(c, 10), value === null ? '' : String(value)); |
| + }, |
| + onselection: function (instance, x1, y1) { |
| + selection = { r: y1, c: x1 }; |
| + showFormula(); |
| + }, |
| + /* The in-cell editor should show the formula, not its result. |
| + * The input does not exist yet when this fires, so it is filled |
| + * on the next frame — best effort, because the formula bar is |
| + * the reliable path and this is the convenience. */ |
| + oneditionstart: function (instance, td, c, r) { |
| + var text = raw[key(parseInt(r, 10), parseInt(c, 10))]; |
| + if (!text) return; |
| + requestAnimationFrame(function () { |
| + var input = td.querySelector('input, textarea'); |
| + if (input) { input.value = text; input.select(); } |
| + }); |
| + }, |
| + |
| worksheets: [{ |
| data: data, |
| minDimensions: [cols, rows], |
| @@ -133,27 +161,7 @@ |
| columnSorting: false, /* sorting is a later slice; a half-done sort is worse than none */ |
| columnDrag: false, |
| allowInsertRow: false, |
| - allowInsertColumn: false, |
| - onchange: function (instance, cell, c, r, value) { |
| - if (applying) return; |
| - write(parseInt(r, 10), parseInt(c, 10), value === null ? '' : String(value)); |
| - }, |
| - onselection: function (instance, x1, y1) { |
| - selection = { r: y1, c: x1 }; |
| - showFormula(); |
| - }, |
| - /* The in-cell editor should show the formula, not its result. |
| - * The input does not exist yet when this fires, so it is filled |
| - * on the next frame — best effort, because the formula bar is |
| - * the reliable path and this is the convenience. */ |
| - oneditionstart: function (instance, td, c, r) { |
| - var text = raw[key(parseInt(r, 10), parseInt(c, 10))]; |
| - if (!text) return; |
| - requestAnimationFrame(function () { |
| - var input = td.querySelector('input, textarea'); |
| - if (input) { input.value = text; input.select(); } |
| - }); |
| - } |
| + allowInsertColumn: false |
| }] |
| }); |
| ws = sheet.worksheets ? sheet.worksheets[0] : sheet[0]; |
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css| index 70adf65..e44b522 100644 |
| --- a/internal/sheets/static/sheets.css |
| +++ b/internal/sheets/static/sheets.css |
| @@ -29,13 +29,22 @@ |
| [data-sheet-title] { |
| font-weight: 600; |
| - margin-inline-end: auto; |
| +} |
| + |
| +/* The formula bar takes the slack, up to a point. Full width pushes |
| + * every control off to the right on a wide screen; a fixed width |
| + * truncates a long formula on a narrow one. */ |
| +[data-formula-input] { |
| + flex: 1 1 12rem; |
| + max-inline-size: 40rem; |
| + font-family: var(--rst-font-mono, ui-monospace, monospace); |
| } |
| /* The live indicator. It says connected or reconnecting and nothing |
| * else — presence is a later slice, and a dot that claims more than it |
| * knows is worse than no dot. */ |
| [data-sheet-live] { |
| + margin-inline-start: auto; |
| font-size: var(--rst-fs-sm, 0.875rem); |
| color: var(--rst-text-mut); |
| } |
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html| index d8b015c..0982227 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -31,7 +31,7 @@ |
| shows its computed value, which is the difference between a |
| spreadsheet and a table of numbers. It is also the reliable way to |
| see a formula: the in-cell editor swap is best effort. */}} |
| -<label rst-sr-only for="formula">Selected cell</label> |
| +<label class="rst-sr-only" for="formula">Selected cell</label> |
| <input rst-input id="formula" data-formula-input type="text" autocomplete="off" |
| placeholder="Select a cell"> |
| {{/* Every drag-only affordance has a menu equivalent (AGENTS.md). |
diff --git a/tools/drive/drive.mjs b/tools/drive/drive.mjs| new file mode 100644 |
| index 0000000..3946dfb |
| --- /dev/null |
| +++ b/tools/drive/drive.mjs |
| @@ -0,0 +1,152 @@ |
| +/* Drive the grid in two real browser tabs. |
| + * |
| + * This is NOT part of `make test`, and deliberately so: it needs a |
| + * browser and a running instance, neither of which CI has. It is a |
| + * manual gate, run before a canary is called good. |
| + * |
| + * It earns its place. Two bugs got through the entire Go suite and |
| + * showed up only here: |
| + * |
| + * 1. Jspreadsheet v5 takes its event handlers on the ROOT config, |
| + * not on a worksheet. Nested one level too deep they are accepted |
| + * in silence and never called: the grid rendered, the console was |
| + * clean, and not one edit was ever sent to the server. |
| + * 2. `rst-sr-only` written as an attribute instead of a class. It is |
| + * one of the seven class-only utilities, so it matched nothing and |
| + * a screen-reader label rendered in the middle of the toolbar. |
| + * |
| + * The second is now caught by a guard test. The first cannot be — no |
| + * static check knows which options a third-party library reads. |
| + * |
| + * Usage, from the repository root: |
| + * |
| + * go build -o /tmp/sheets ./cmd/sheets |
| + * SHEETS_ORIGIN=http://127.0.0.1:8799 \ |
| + * SHEETS_INSTANCE_KEY=dev SHEETS_OWNERS=you@example.com \ |
| + * SHEETS_DEK="$(head -c 32 /dev/urandom | base64)" \ |
| + * /tmp/sheets --db /tmp/s.db --socket /tmp/s.sock > /tmp/s.log 2>&1 & |
| + * SHEETS_SOCK=/tmp/s.sock SHEETS_LOG=/tmp/s.log node tools/drive/drive.mjs |
| + * |
| + * It reads the magic link out of the log, which only works because a |
| + * loopback origin with no SMTP logs links instead of mailing them — |
| + * see internal/sheets/auth.go. A deployed instance never does this. |
| + * |
| + * PLAYWRIGHT is wherever playwright is installed; there is no |
| + * package.json here, because this app has no build step and is not |
| + * about to grow one for a manual script. |
| + */ |
| +import net from 'node:net'; |
| +import fs from 'node:fs'; |
| +const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright'; |
| +const { chromium } = await import(PLAYWRIGHT); |
| + |
| +const SOCK = process.env.SHEETS_SOCK || '/tmp/s.sock'; |
| +const LOG = process.env.SHEETS_LOG || '/tmp/s.log'; |
| +const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10); |
| + |
| +// TCP -> unix socket, so the browser has a real origin to use. The app |
| +// speaks the platform's activation contract (a socket path, no $PORT), |
| +// so this stands in for the edge that terminates HTTP in production. |
| +const proxy = net.createServer((c) => { |
| + const u = net.connect(SOCK); |
| + c.pipe(u); u.pipe(c); |
| + c.on('error', () => u.destroy()); |
| + u.on('error', () => c.destroy()); |
| +}); |
| +await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r)); |
| + |
| +const base = `http://127.0.0.1:${PORT}`; |
| +const browser = await chromium.launch(); |
| +const ctx = await browser.newContext(); |
| +const page = await ctx.newPage(); |
| + |
| +const errors = []; |
| +ctx.on('weberror', (e) => errors.push('weberror: ' + e.error().message)); |
| +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); |
| +page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); |
| + |
| +// 1. Sign in, the way a person does. |
| +const before = fs.readFileSync(LOG, 'utf8').length; |
| +await page.goto(base + '/signin'); |
| +await page.fill('#address', 'paul@example.com'); |
| +await page.click('form[action="/signin"] button[type=submit]'); |
| +await page.waitForURL(/signin\?sent=1/, { timeout: 15000 }); |
| + |
| +const fresh = fs.readFileSync(LOG, 'utf8').slice(before); |
| +const m = fresh.match(/auth\/verify\?token=([A-Za-z0-9_-]+)/); |
| +if (!m) { console.log('FAIL: no magic link in the log'); process.exit(1); } |
| +await page.goto(`${base}/auth/verify?token=${m[1]}`); |
| +console.log('signed in as:', (await page.textContent('body')).includes('Signed in as') ? 'yes' : 'NO'); |
| + |
| +// 2. Make a sheet. |
| +await page.goto(base + '/sheets'); |
| +await page.fill('#title', 'Browser drive'); |
| +// Not networkidle: the SSE stream is a long-lived connection, so the |
| +// network on this page is never idle by design. |
| +await page.click('form[action="/sheets"] button[type=submit]'); |
| +await page.waitForURL(/\/sheets\/\d+$/, { timeout: 15000 }); |
| +const url = page.url(); |
| +console.log('sheet url:', url); |
| + |
| +// 3. The grid must actually render. |
| +await page.waitForSelector('.jexcel, .jss_worksheet, table', { timeout: 15000 }); |
| +console.log('grid rendered:', await page.locator('[data-sheet-grid] table').count() > 0); |
| +await page.waitForFunction( |
| + () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', |
| + null, { timeout: 15000 }).catch(() => {}); |
| +console.log('live indicator:', await page.getAttribute('[data-sheet-live]', 'data-state'), |
| + JSON.stringify(await page.textContent('[data-sheet-live]'))); |
| + |
| +// 4. A SECOND tab on the same sheet. |
| +const page2 = await ctx.newPage(); |
| +page2.on('pageerror', (e) => errors.push('tab2 pageerror: ' + e.message)); |
| +await page2.goto(url); |
| +await page2.waitForSelector('[data-sheet-grid] table', { timeout: 15000 }); |
| +await page2.waitForFunction( |
| + () => document.querySelector('[data-sheet-live]')?.dataset.state === 'on', |
| + null, { timeout: 15000 }).catch(() => {}); |
| +console.log('tab2 live:', await page2.getAttribute('[data-sheet-live]', 'data-state')); |
| + |
| +// 5. Type into tab 1. |
| +const cellA1 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); |
| +await cellA1.dblclick(); |
| +await page.keyboard.type('6'); |
| +await page.keyboard.press('Enter'); |
| + |
| +const cellA2 = page.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); |
| +await cellA2.dblclick(); |
| +await page.keyboard.type('=A1*7'); |
| +await page.keyboard.press('Enter'); |
| +await page.waitForTimeout(1500); |
| + |
| +console.log('tab1 A1:', JSON.stringify(await cellA1.textContent())); |
| +console.log('tab1 A2:', JSON.stringify(await cellA2.textContent())); |
| + |
| +// 6. THE TEST: does tab 2 show it without a reload? |
| +const t2a1 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="0"]'); |
| +const t2a2 = page2.locator('[data-sheet-grid] td[data-x="0"][data-y="1"]'); |
| +let ok = false; |
| +for (let i = 0; i < 40; i++) { |
| + const a1 = (await t2a1.textContent())?.trim(); |
| + const a2 = (await t2a2.textContent())?.trim(); |
| + if (a1 === '6' && a2 === '42') { ok = true; break; } |
| + await page2.waitForTimeout(250); |
| +} |
| +console.log('tab2 A1:', JSON.stringify((await t2a1.textContent())?.trim())); |
| +console.log('tab2 A2:', JSON.stringify((await t2a2.textContent())?.trim())); |
| +console.log(ok ? 'LIVE-ACROSS-TABS: YES' : 'LIVE-ACROSS-TABS: NO'); |
| + |
| +// 7. The formula bar shows the input, not the result. |
| +await t2a2.click(); |
| +await page2.waitForTimeout(300); |
| +console.log('tab2 formula bar for A2:', JSON.stringify(await page2.inputValue('[data-formula-input]'))); |
| + |
| +const shots = process.env.SHEETS_SHOTS; |
| +if (shots) { |
| + await page.screenshot({ path: shots + '/tab1.png' }); |
| + await page2.screenshot({ path: shots + '/tab2.png' }); |
| +} |
| + |
| +console.log('js errors:', errors.length ? JSON.stringify(errors.slice(0, 8), null, 1) : 'none'); |
| +await browser.close(); |
| +proxy.close(); |