WFDASH-WEBAPP/src/lib/pos-inline-handlers.test.ts
Dave bcd9b88dd4 fix(pos): revive the dead "Print cash-up" button, add 20c/10c denominations
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.

Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.

On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.

Also here:

- 20c and 10c added to the drawer count, with the total rounded to whole
  cents. Neither is exact in binary floating point, so 3x10c would
  otherwise have summed to 0.30000000000000004 and printed a variance
  that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
  printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
  thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
  cleared settings would land there and look dead all over again. It now
  fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
  cash-up renderer yet — only the sale slip speaks Bluetooth.

pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.

src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.

SW cache bumped to pos-v26 so the tills pick the files up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:30:11 +02:00

120 lines
4 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
/**
* The POS till pages under `public/pos` are plain HTML wired with inline
* `onclick="doThing()"` attributes. An inline handler is evaluated in the
* GLOBAL scope, so `doThing` must be reachable off `window`.
*
* Most of these pages wrap their script in an IIFE. A function declared
* inside that IIFE is NOT global, so the handler throws
* `ReferenceError: doThing is not defined` — which surfaces only as an
* uncaught console error. The button looks completely dead: no dialog,
* no feedback, nothing.
*
* This has now bitten the till twice (receipt.html's print buttons, then
* cashup.html's "Print cash-up"), so it gets a guard. The rule:
*
* every identifier called from an inline on* attribute must be either a
* browser builtin, a namespaced call (`window.Foo.bar()`), or exported
* with `window.name = name`.
*/
const POS_DIR = join(process.cwd(), "public", "pos");
/** Browser globals that inline handlers legitimately call. */
const BUILTINS = new Set([
"alert",
"confirm",
"print",
"open",
"Number",
"String",
"parseInt",
"parseFloat",
"encodeURIComponent",
]);
/**
* Drop the run of leading comments/whitespace so we can look at the first
* real statement. Deliberately anchored and non-global: a line-comment
* pattern led by `\s` and flagged `m` matches across newlines, which
* silently eats real code rather than just the comments.
*/
const firstStatement = (src: string) =>
src.replace(/^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/, "");
/** A script whose whole body is `(function(){ ... })()` leaks nothing. */
const isIifeWrapped = (src: string) =>
/^\(\s*(?:function\s*\([^)]*\)|\([^)]*\)\s*=>)\s*\{/.test(
firstStatement(src),
);
/** Names this source makes reachable from global scope. */
function globalsFrom(src: string): Set<string> {
const names = new Set<string>();
// Explicit exports work regardless of wrapping.
for (const m of src.matchAll(/window\.([A-Za-z_$][\w$]*)\s*=/g)) {
names.add(m[1]);
}
// Top-level declarations are global only when not IIFE-wrapped.
if (!isIifeWrapped(src)) {
for (const m of src.matchAll(
/^(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/gm,
)) {
names.add(m[1]);
}
for (const m of src.matchAll(
/^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\()/gm,
)) {
names.add(m[1]);
}
}
return names;
}
/** Identifiers invoked from inline `on*="..."` attributes, ignoring `a.b()`. */
function inlineHandlerCalls(html: string): Set<string> {
const calls = new Set<string>();
for (const attr of html.matchAll(/\son[a-z]+\s*=\s*"([^"]*)"/g)) {
for (const call of attr[1].matchAll(
/(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g,
)) {
calls.add(call[1]);
}
}
return calls;
}
const htmlFiles = readdirSync(POS_DIR).filter((f) => f.endsWith(".html"));
describe("POS inline event handlers are reachable from global scope", () => {
it.each(htmlFiles)("%s has no dead inline handlers", (file) => {
const html = readFileSync(join(POS_DIR, file), "utf8");
// Everything this page executes: its own inline blocks plus local scripts.
const sources: string[] = [
...[...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map(
(m) => m[1],
),
...[...html.matchAll(/<script[^>]*\ssrc="([^"]+\.js)"/g)]
.map((m) => m[1])
.filter((src) => !src.startsWith("http"))
.map((src) => readFileSync(join(POS_DIR, src), "utf8")),
];
const reachable = new Set<string>();
for (const src of sources) {
for (const name of globalsFrom(src)) reachable.add(name);
}
const dead = [...inlineHandlerCalls(html)]
.filter((name) => !BUILTINS.has(name) && !reachable.has(name))
.sort();
expect(dead, `${file}: inline handler(s) not reachable on window`).toEqual(
[],
);
});
});