@bongos/core 1.24.83 → 1.24.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.bongos-core.json +62 -62
  2. package/docs/copy-inventory.md +2 -2
  3. package/docs/copy-registry.json +2 -2
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +33 -33
  6. package/docs/recipes/autobongos-runner-setup.md +1 -0
  7. package/modules/hall-ui/public/ai-studio.html +1 -1
  8. package/modules/hall-ui/public/atlas.html +1 -1
  9. package/modules/hall-ui/public/blockers.html +1 -1
  10. package/modules/hall-ui/public/board-room.html +1 -1
  11. package/modules/hall-ui/public/city.css +3 -2
  12. package/modules/hall-ui/public/city.html +4 -4
  13. package/modules/hall-ui/public/collab.html +1 -1
  14. package/modules/hall-ui/public/commands.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/goal-map.html +3 -3
  18. package/modules/hall-ui/public/goals.html +1 -1
  19. package/modules/hall-ui/public/government.html +1 -1
  20. package/modules/hall-ui/public/home-city.css +0 -4
  21. package/modules/hall-ui/public/idea.html +1 -1
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +4 -4
  24. package/modules/hall-ui/public/market-watch-item.html +1 -1
  25. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  26. package/modules/hall-ui/public/market-watch.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/money.html +1 -1
  29. package/modules/hall-ui/public/my-agents.html +1 -1
  30. package/modules/hall-ui/public/primer.html +1 -1
  31. package/modules/hall-ui/public/profile.html +1 -1
  32. package/modules/hall-ui/public/project-settings.html +1 -1
  33. package/modules/hall-ui/public/roster.html +1 -1
  34. package/modules/hall-ui/public/settings.html +1 -1
  35. package/modules/hall-ui/public/street.html +1 -1
  36. package/modules/hall-ui/public/studio.html +1 -1
  37. package/modules/hall-ui/public/style.css +21 -11
  38. package/modules/hall-ui/public/task.html +1 -1
  39. package/modules/hall-ui/public/thinking.html +3 -3
  40. package/modules/hall-ui/public/tweak-editor.html +1 -1
  41. package/modules/hall-ui/public/visor.css +108 -47
  42. package/modules/hall-ui/public/visor.js +50 -33
  43. package/modules/hall-ui/public/watch.html +1 -1
  44. package/modules/hall-ui/public/work.html +1 -1
  45. package/modules/hall-ui/records/visor.md +14 -2
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +12 -0
  49. package/scripts/bongos/autobongos-bootstrap.sh +24 -2
  50. package/scripts/bongos/autobongos-host.js +32 -2
  51. package/scripts/bongos/wedge-remedy.js +9 -2
  52. package/src/module-api.js +1 -1
  53. package/tests/autobongos_host.mjs +33 -0
  54. package/tests/hall_visor.mjs +128 -10
  55. package/tests/shell_stat_portability.mjs +34 -0
  56. package/tests/ship_vendored_instance.mjs +5 -1
  57. package/tests/update_sweep_home.mjs +11 -5
  58. package/tests/wedge_remedy.mjs +5 -1
@@ -59,6 +59,28 @@ esac
59
59
 
60
60
  have() { command -v "$1" >/dev/null 2>&1; }
61
61
 
62
+ # github_remedy — what to actually DO when git cannot see the project (task 1005096).
63
+ #
64
+ # THE CIRCLE THIS BREAKS, found on a real Mac. "Sign in to GitHub" is the wrong advice for
65
+ # the commonest way this fails: the person IS signed in — twice — and git picked the other
66
+ # one. git asks its FIRST credential helper, which on a Mac is the login keychain, and that
67
+ # answers with whichever GitHub account is active, not with one that can see this project.
68
+ # A private project then 404s as "Repository not found", which reads as a typo rather than
69
+ # as the wrong account, and `gh auth login` again changes nothing.
70
+ #
71
+ # So when the GitHub CLI is here, name the account that is actually active and the one
72
+ # command that changes it. With no gh, the old advice is still the right advice.
73
+ github_remedy() {
74
+ if have gh; then
75
+ active="$(gh auth status --active 2>/dev/null | sed -n 's/.*account \([A-Za-z0-9-]*\).*/\1/p' | head -n 1)"
76
+ if [ -n "${active:-}" ]; then
77
+ echo "git is using your '$active' GitHub account, which may not be able to see this project. Switch with gh auth switch (it lists the accounts signed in here), then paste the setup command again. If the right account is not listed, add it with gh auth login first."
78
+ return
79
+ fi
80
+ fi
81
+ echo "Sign in to GitHub on this device with an account that can see the project (the GitHub CLI's gh auth login is the simplest way), then paste the setup command again."
82
+ }
83
+
62
84
  # The setup command is piped into bash, so the keyboard is /dev/tty, not stdin.
63
85
  # OPENED, not just tested: with no terminal at all (CI, a remote command) the
64
86
  # device node still exists and reads as readable, and opening it is what fails.
@@ -133,12 +155,12 @@ mkdir -p "$RUNNER_HOME"
133
155
  if [ -d "$REPO_DIR/.git" ]; then
134
156
  echo "The runner's copy is already here; bringing it up to date..."
135
157
  if [ "$HAVE_TTY" = 1 ]; then git -C "$REPO_DIR" fetch --quiet origin main </dev/tty; else git -C "$REPO_DIR" fetch --quiet origin main; fi \
136
- || stop_setup "this device could not fetch the project's code from GitHub." "Sign in to GitHub on this device (the GitHub CLI's gh auth login is the simplest way), then paste the setup command again."
158
+ || stop_setup "this device could not fetch the project's code from GitHub." "$(github_remedy)"
137
159
  git -C "$REPO_DIR" reset --quiet --hard origin/main
138
160
  else
139
161
  echo "Making the runner's own copy of the project (GitHub may ask you to sign in)..."
140
162
  if [ "$HAVE_TTY" = 1 ]; then git clone --quiet "$REPO" "$REPO_DIR" </dev/tty; else git clone --quiet "$REPO" "$REPO_DIR"; fi \
141
- || stop_setup "this device could not copy the project from GitHub." "Sign in to GitHub on this device with an account that can see the project (the GitHub CLI's gh auth login is the simplest way), then paste the setup command again."
163
+ || stop_setup "this device could not copy the project from GitHub." "$(github_remedy)"
142
164
  fi
143
165
 
144
166
  HOST_JS="$REPO_DIR/scripts/bongos/autobongos-host.js"
@@ -125,6 +125,28 @@ class Stop extends Error {
125
125
  constructor(what, fix) { super(what); this.name = 'Stop'; this.fix = fix || null; }
126
126
  }
127
127
 
128
+ // githubRemedy — what to DO when git cannot see the project (task 1005096). The JS twin of
129
+ // autobongos-bootstrap.sh's github_remedy; the two say the same thing because a person can
130
+ // meet either one, and the bootstrap's own header carries the full reasoning.
131
+ //
132
+ // IN SHORT: "sign in to GitHub" is the wrong advice for the commonest failure. git asks its
133
+ // FIRST credential helper — on a Mac, the login keychain — which answers with whichever
134
+ // GitHub account is ACTIVE, not with one that can see this project. A private project then
135
+ // 404s as "Repository not found", which reads as a typo rather than as the wrong account,
136
+ // and signing in again changes nothing. So name the active account when the GitHub CLI can
137
+ // tell us, and give the one command that changes it.
138
+ const GITHUB_REMEDY_GENERIC = 'Sign in to GitHub on this device with an account that can see the project (the GitHub CLI\'s gh auth login is the simplest way), then run the setup command again.';
139
+ function githubRemedy(run = spawnSync) {
140
+ try {
141
+ const r = run('gh', ['auth', 'status', '--active'], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
142
+ const m = /account ([A-Za-z0-9-]+)/.exec(String((r && r.stdout) || '') + String((r && r.stderr) || ''));
143
+ if (m) {
144
+ return `git is using your '${m[1]}' GitHub account, which may not be able to see this project. Switch with gh auth switch (it lists the accounts signed in here), then run the setup command again. If the right account is not listed, add it with gh auth login first.`;
145
+ }
146
+ } catch (_) { /* no gh, or it could not answer — the generic line is still right */ }
147
+ return GITHUB_REMEDY_GENERIC;
148
+ }
149
+
128
150
  // ── the runner's own copy ──────────────────────────────────────────────────
129
151
 
130
152
  function runnerCopyMarkPath(repoRoot = REPO_ROOT) { return path.join(repoRoot, '.git', RUNNER_COPY_MARK); }
@@ -289,7 +311,14 @@ function git(args, opts = {}) {
289
311
  function refreshCheckout(log = () => {}) {
290
312
  assertRunnerCopy();
291
313
  const f = git(['fetch', '--quiet', 'origin', 'main']);
292
- if (f.status !== 0) { log(`git fetch failed — running the code already here (${String(f.stderr || '').trim().slice(0, 200)})`); return false; }
314
+ if (f.status !== 0) {
315
+ // The log is the ONLY place this is said (the card reads check-ins, not fetches), so it
316
+ // carries the remedy too — a fetch that fails for a credential reason fails every loop,
317
+ // and the runner then works forever on the code it happens to have (task 1005096).
318
+ log(`git fetch failed — running the code already here (${String(f.stderr || '').trim().slice(0, 200)})`);
319
+ log(` if that keeps happening, it is not transient: ${githubRemedy()}`);
320
+ return false;
321
+ }
293
322
  const r = git(['reset', '--quiet', '--hard', 'origin/main']);
294
323
  if (r.status !== 0) { log(`git reset failed (${String(r.stderr || '').trim().slice(0, 200)})`); return false; }
295
324
  return true;
@@ -726,7 +755,7 @@ async function setup(argv, { paths = hostPaths(), say = console.log } = {}) {
726
755
 
727
756
  say('4/5 Checking this device can fetch the project’s code…');
728
757
  if (git(['ls-remote', 'origin', 'refs/heads/main'], { timeout: 60000 }).status !== 0) {
729
- throw new Stop('This device could not fetch the project’s code from GitHub.', 'Sign in to GitHub on this device (run git fetch in the runner’s folder and follow the sign-in), then run the setup command again.');
758
+ throw new Stop('This device could not fetch the project’s code from GitHub.', githubRemedy());
730
759
  }
731
760
 
732
761
  say('5/5 Starting the runner…');
@@ -895,4 +924,5 @@ module.exports = {
895
924
  windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
896
925
  stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
897
926
  signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
927
+ githubRemedy, GITHUB_REMEDY_GENERIC,
898
928
  };
@@ -90,12 +90,19 @@ function planRemedy(inst, reason, o = {}) {
90
90
  if (!SHELL_SAFE_RE.test(backupDir)) return { refused: 'the backup folder is not a path this runner will put in a command' };
91
91
  // EXACTLY db-backup-nightly.sh's name, so this never touches a sibling project's dumps
92
92
  // (`test` must not prune `test-nk`'s — task 1003948). The timestamp sorts by name, so
93
- // `sort | head -n -K` drops all but the newest K. Nothing is deleted when K or fewer exist.
93
+ // dropping all but the last K leaves the newest K. Nothing is deleted when K or fewer exist.
94
+ //
95
+ // ALL-BUT-THE-LAST-K IN POSIX awk, NOT `head -n -K` (task 1005096). `head -n -K` is a GNU
96
+ // extension: BSD/macOS head answers `illegal line count`, exits 1, and the pipeline then
97
+ // deletes nothing — and `xargs -r` is GNU-only too. Same family as the `stat -c` sweep
98
+ // (task 1004170): the command runs on a Linux box, but the test that runs it for real runs
99
+ // wherever a builder builds, so on a Mac it could never pass. POSIX `xargs` with no input
100
+ // may still run `rm -f --`, which succeeds with no operands, so `-r` is not needed.
94
101
  const D = '[0-9]';
95
102
  const glob = `${db}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.sql.gz`;
96
103
  return {
97
104
  reason, cwd: null, env: null,
98
- cmd: `find ${backupDir} -maxdepth 1 -type f -name '${glob}' | sort | head -n -${DUMP_KEEP_FLOOR} | xargs -r rm -f --`,
105
+ cmd: `find ${backupDir} -maxdepth 1 -type f -name '${glob}' | sort | awk -v k=${DUMP_KEEP_FLOOR} '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}' | xargs rm -f --`,
99
106
  describe: `pruned ${db}'s old database backups in ${backupDir}, keeping the newest ${DUMP_KEEP_FLOOR}`,
100
107
  };
101
108
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.85'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -663,3 +663,36 @@ test('the digest is read from the RUNNER’s run log: bare, the digest CLI looks
663
663
  assert.equal(bare.status, 0, 'the digest never fails a morning');
664
664
  assert.equal(bare.stdout.includes('424242'), false, 'which is why the guide sends people through the device program');
665
665
  });
666
+
667
+ // --- "sign in to GitHub" is the wrong advice when you already are (task 1005096) -------
668
+ //
669
+ // Found by running the real setup on a real Mac. The device had TWO GitHub accounts signed
670
+ // in through the GitHub CLI; git asks its first credential helper — the login keychain —
671
+ // which answers with whichever is ACTIVE, and that one could not see the private project.
672
+ // git then says "Repository not found", which reads as a typo, and the setup answered
673
+ // "Sign in to GitHub … gh auth login is the simplest way" — advice that changes nothing,
674
+ // because the person is signed in. The remedy has to NAME the account git is using.
675
+ test('githubRemedy names the active GitHub account, and the command that changes it', () => {
676
+ const said = host.githubRemedy(() => ({ stdout: 'github.com\n ✓ Logged in to github.com account mcip-labs (keyring)\n', stderr: '' }));
677
+ assert.match(said, /'mcip-labs'/, 'the account git is actually using must be named, or the advice is a circle');
678
+ assert.match(said, /gh auth switch/, 'and the one command that changes it');
679
+ assert.notEqual(said, host.GITHUB_REMEDY_GENERIC);
680
+ });
681
+
682
+ test('githubRemedy falls back to the plain advice when gh cannot answer', () => {
683
+ // No GitHub CLI on the device, it failed, or it is signed in to nothing: there is no
684
+ // account to name, and "sign in" is then exactly the right thing to say.
685
+ assert.equal(host.githubRemedy(() => ({ stdout: '', stderr: 'command not found' })), host.GITHUB_REMEDY_GENERIC);
686
+ assert.equal(host.githubRemedy(() => { throw new Error('ENOENT'); }), host.GITHUB_REMEDY_GENERIC);
687
+ assert.equal(host.githubRemedy(() => ({ stdout: 'You are not logged into any GitHub hosts.\n', stderr: '' })), host.GITHUB_REMEDY_GENERIC);
688
+ });
689
+
690
+ test('the shell bootstrap gives the same remedy, so either half says the same thing', () => {
691
+ const sh = readFileSync(fileURLToPath(new URL('../scripts/bongos/autobongos-bootstrap.sh', import.meta.url)), 'utf8');
692
+ assert.match(sh, /github_remedy\(\)/, 'the bootstrap has the twin');
693
+ assert.match(sh, /gh auth switch/);
694
+ // Both clone/fetch stops route through it rather than re-typing the old line.
695
+ const stops = sh.split('\n').filter((l) => /stop_setup "this device could not (copy|fetch)/.test(l));
696
+ assert.equal(stops.length, 2, 'the two GitHub stops');
697
+ for (const l of stops) assert.match(l, /\$\(github_remedy\)/, l.trim());
698
+ });
@@ -8,6 +8,8 @@
8
8
  // - in game mode the last Escape reaches the options menu, never the visor.
9
9
  // Plus the criterion's own proof half that is the visor's: an Artisan and an Artist
10
10
  // are offered DIFFERENT actions on the glass. The pure parts run in a vm.
11
+ // The minimal visor (Helm look 3, task 1005195) adds the look's own pins: the sheet spends
12
+ // tokens only, and over the hall's real sheets the foot row holds at a desk, 390 and 320.
11
13
  import assert from 'node:assert/strict';
12
14
  import { test, after } from 'node:test';
13
15
  import { createRequire } from 'node:module';
@@ -87,6 +89,35 @@ test('source: the visor loads after the shell on every scene page, with its shee
87
89
  assert.match(read('visor.css'), /pointer-events: none/, 'the glass lets the pointer through');
88
90
  });
89
91
 
92
+ // Helm look 3 (task 1005195): the glass is the kit's, and nothing on it is typed in.
93
+ test('source: the minimal visor spends the Helm tokens and types in no colour, size, corner, shadow or breakpoint', () => {
94
+ const css = read('visor.css').replace(/\/\*[\s\S]*?\*\//g, '');
95
+ assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(/i, 'no colour literal');
96
+ assert.doesNotMatch(css, /font-size:\s*[\d.]/, 'every size is a --ds-text-* step');
97
+ assert.doesNotMatch(css, /border-radius:(?!\s*var\(--press-radius\))/, 'no corner but the press corner: Helm draws no pill');
98
+ assert.doesNotMatch(css, /@media|@container/, 'the phone rules live in style.css (the design-count ratchet)');
99
+ assert.doesNotMatch(css, /animation|@keyframes/, 'nothing on the glass moves by itself');
100
+ for (const m of css.match(/(?:box|text)-shadow:[^;]+/g) || []) assert.match(m, /^(?:box|text)-shadow:\s*(?:none|var\(--[a-z-]+\))$/, `${m}: a shadow is a token`);
101
+ for (const m of css.match(/transition:[^;]+/g) || []) assert.match(m, /var\(--ds-dur-/, `${m}: a duration token, so reduced motion stills it`);
102
+ assert.match(css, /\.visor__main \{[^}]*background: var\(--accent\)[^}]*clip-path: var\(--cut-path\)/, 'the one accent button is cut');
103
+ assert.match(css, /\.visor__main:focus-visible[^{]*\{[^}]*box-shadow: var\(--press-focus\)/, 'a cut control takes the inset ring');
104
+ assert.match(css, /\.visor__hint \{[^}]*background: none; border: 0/, 'a key hint has no box');
105
+ assert.match(css, /\.visor__place, \.visor__name, \.visor__unit, \.visor__main, \.visor__hint[^{]*\{\s*font-family: var\(--name-face\)/, 'names are in the name voice');
106
+ // it reads over any sky with one treatment, never a per-hour style
107
+ assert.match(css, /html\[data-game="on"\] \.visor:not\(\[hidden\]\) \{[^}]*background: linear-gradient\(to bottom, var\(--night-glass\), transparent[^;]*var\(--night-glass\)\)/, 'the glass darkens toward its edges');
108
+ assert.match(css, /text-shadow: var\(--type-halo\); -webkit-text-stroke: 4px var\(--night-glass\); paint-order: stroke fill/, 'type wears the halo over a stroke of the night');
109
+ assert.match(css, /\.visor__line--under \{ stroke: var\(--night-glass\)/, 'every line is drawn over the night');
110
+ assert.doesNotMatch(css, /data-hour|data-time|data-sky/, 'no per-hour style');
111
+ // the frame is two arcs, a notch and a sight; the compass, the side lines and the pills are gone
112
+ assert.equal((VISOR.match(/svgOpen\('visor__frame /g) || []).length, 2, 'two arcs');
113
+ assert.doesNotMatch(VISOR + css, /visor__cell|visor__mark|visor__line--side|visor__pill/, 'the old frame and the pills are gone');
114
+ const phone = read('style.css').match(/tasks 1005148 and 1005195, the visor[\s\S]*?\n\}/);
115
+ assert.ok(phone, 'the phone rules sit in the shared phone block');
116
+ assert.match(phone[0], /\.visor__hint:not\(\[data-visor-sheet\]\) \{ display: none; \}/, 'a phone keeps only Actions of the hints');
117
+ assert.doesNotMatch(read('home-city.css'), /visor-on/, 'Home has no top-right pill to clear any more');
118
+ assert.match(read('city.css'), /\nhtml\.visor-on \.city-door \{ bottom: 150px; \}/, 'the city\'s door clears the centred button at every width');
119
+ });
120
+
90
121
  // ---------- in a browser: the glass over a stand-in scene ----------
91
122
  const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
92
123
  let browser = null;
@@ -95,7 +126,7 @@ after(() => browser && browser.close());
95
126
  const skip = !browser && 'no system Edge or Chrome';
96
127
 
97
128
  const ORIGIN = 'http://hall.test';
98
- const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' };
129
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' };
99
130
  // The stand-in scene: a canvas filling the window that pans on drag, and the sky's Escape rule.
100
131
  const SCENE = `<script>
101
132
  window.cam = { x: 0, y: 0 }; window.picked = true; window.optionsOpened = 0;
@@ -109,18 +140,23 @@ const SCENE = `<script>
109
140
  me() { return { builder: { total_credits: 56021 } }; }, spriteSrc(id) { return '/builders/characters/' + id + '.png'; }, openTool(id) { window.toolOpened = id; } };
110
141
  window.__visorScene = { name: 'test', place: 'Test city', actions: [{ label: 'Back to centre', key: '0', run() { window.centred = true; } }], keys: [{ label: 'Look around', key: 'drag' }] };
111
142
  </script>`;
112
- const PAGE = (game) => `<!doctype html><html${game ? ' data-game="on"' : ''}><head><meta charset="utf-8">
143
+ // `helm` links the hall's real sheet (the token tier) instead of the stand-in colours.
144
+ const PAGE = (game, helm) => helm ? `<!doctype html><html data-theme="dark" data-faces="helm"${game ? ' data-game="on"' : ''}><head><meta charset="utf-8">
145
+ <link rel="stylesheet" href="/builders/style.css"><link rel="stylesheet" href="/builders/visor.css">
146
+ <style>body{margin:0}#world{position:fixed;inset:0;width:100vw;height:100vh;display:block}</style></head>
147
+ <body><canvas id="world"></canvas>${SCENE}<script src="/builders/shell-game.js"></script><script src="/builders/visor.js"></script></body></html>`
148
+ : `<!doctype html><html${game ? ' data-game="on"' : ''}><head><meta charset="utf-8">
113
149
  <link rel="stylesheet" href="/builders/visor.css"><style>:root{--ink:#111;--ink-soft:#555;--ink-faint:#777;--rule:#ccc;--rule-soft:#ddd;--bg-card:#fff;--bg-deep:#eee;--accent:#fb9569;--accent-on:#111;--accent-deep:#a3481a;--focus:#a3481a}
114
150
  body{margin:0}#world{position:fixed;inset:0;width:100vw;height:100vh;display:block}</style></head>
115
151
  <body><canvas id="world"></canvas>${SCENE}<script src="/builders/shell-game.js"></script><script src="/builders/visor.js"></script></body></html>`;
116
152
 
117
- async function open({ game = true } = {}) {
118
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
153
+ async function open({ game = true, helm = false, width = 1280, height = 800 } = {}) {
154
+ const ctx = await browser.newContext({ viewport: { width, height } });
119
155
  await ctx.route(ORIGIN + '/**', (route) => {
120
156
  const p = new URL(route.request().url()).pathname;
121
- if (p === '/' ) return route.fulfill({ contentType: 'text/html', body: PAGE(game) });
122
- const f = path.join(HALL, p.replace(/^\/builders\//, ''));
123
- if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
157
+ if (p === '/' ) return route.fulfill({ contentType: 'text/html', body: PAGE(game, helm) });
158
+ const f = p.startsWith('/modules/') ? path.join(ROOT, p) : path.join(HALL, p.replace(/^\/builders\//, ''));
159
+ if ((p.startsWith('/builders/') || p.startsWith('/modules/')) && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
124
160
  return route.fulfill({ status: 404, body: '' });
125
161
  });
126
162
  const page = await ctx.newPage();
@@ -139,12 +175,16 @@ test('outside game mode the hall is as it was: no glass', { skip }, async () =>
139
175
  await ctx.close();
140
176
  });
141
177
 
142
- test('in game mode the glass shows the place, the main action, the sprite and the credits', { skip }, async () => {
178
+ test('in game mode the glass shows the place, the main action, the key hints, the sprite and the credits', { skip }, async () => {
143
179
  const { ctx, page, errors } = await open();
144
180
  const g = await page.evaluate(() => ({
145
181
  shown: getComputedStyle(document.getElementById('visor')).display,
146
182
  place: document.querySelector('.visor__place').textContent,
147
- main: document.querySelector('.visor__pill--main').textContent,
183
+ main: document.querySelector('.visor__main').textContent,
184
+ hints: [...document.querySelectorAll('.visor__hints .visor__hint')].map((h) => h.textContent),
185
+ num: document.querySelector('.visor__num').textContent,
186
+ unit: document.querySelector('.visor__unit').textContent,
187
+ arcs: document.querySelectorAll('.visor__frame').length,
148
188
  who: document.querySelector('.visor__name').textContent,
149
189
  credits: document.querySelector('.visor__credits').textContent,
150
190
  sprite: document.querySelector('.visor__sprite').getAttribute('src'),
@@ -154,6 +194,10 @@ test('in game mode the glass shows the place, the main action, the sprite and th
154
194
  assert.match(g.main, /^Goal sky/, 'Claim a task opens a classic page, so it is off the glass in game mode (task 1005200)');
155
195
  assert.equal(g.who, 'Artisan');
156
196
  assert.equal(g.credits, '56,021 credits');
197
+ assert.deepEqual([g.num, g.unit], ['56,021', 'credits'], 'the count and its word are two spans of the same text');
198
+ // task 1005195: every other control is a quiet key hint. Actions kept its key and its sheet; nothing was dropped.
199
+ assert.deepEqual(g.hints, ['TYour thinking', 'AActions', 'EscOptions']);
200
+ assert.equal(g.arcs, 2, 'two arcs frame the glass');
157
201
  assert.equal(g.sprite, '/builders/characters/artisan.png');
158
202
  assert.deepEqual(errors, []);
159
203
  await ctx.close();
@@ -199,7 +243,10 @@ test('the sheet lists the character\'s actions, then this place, and runs a scen
199
243
  test('an Artist\'s glass offers other actions, and its keys open the Artist\'s tools', { skip }, async () => {
200
244
  const { ctx, page } = await open();
201
245
  await page.evaluate(() => { OTBCraftMode.held = 'artist'; OTBVisor.render(); });
202
- assert.match(await page.textContent('.visor__pill--main'), /^Review this page/);
246
+ assert.match(await page.textContent('.visor__main'), /^Review this page/);
247
+ assert.deepEqual(await page.$$eval('.visor__hints [data-visor-act]', (h) => h.map((x) => x.getAttribute('data-visor-act'))), ['words', 'idea'], 'the Artist\'s other tools are key hints');
248
+ await page.click('.visor__hints [data-visor-act="idea"]');
249
+ assert.equal(await page.evaluate(() => window.toolOpened), 'lightbulb', 'a hint runs its action, like its row in the sheet');
203
250
  await page.keyboard.press('e');
204
251
  assert.equal(await page.evaluate(() => window.toolOpened), 'paintbrush');
205
252
  await ctx.close();
@@ -223,3 +270,74 @@ test('no character, or no scene: no glass', { skip }, async () => {
223
270
  assert.equal(await page.evaluate(() => document.getElementById('visor').hidden), true);
224
271
  await ctx.close();
225
272
  });
273
+
274
+ // ---------- Helm look 3 (task 1005195): the foot row over the hall's real sheets ----------
275
+ const box = (sel) => `(() => [...document.querySelectorAll(${JSON.stringify(sel)})].filter((e) => e.getClientRects().length).map((e) => { const b = e.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; }))()`;
276
+ const apart = (a, b) => a.r <= b.l + 0.5 || b.r <= a.l + 0.5 || a.b <= b.t + 0.5 || b.b <= a.t + 0.5;
277
+
278
+ for (const [width, height] of [[1440, 900], [390, 844], [320, 640]]) {
279
+ test(`at ${width}px the foot row fits: no sideways scroll, nothing collides, every control clears 24px`, { skip }, async () => {
280
+ for (const who of ['artisan', 'artist', 'patron']) {
281
+ const { ctx, page, errors } = await open({ helm: true, width, height });
282
+ await page.evaluate((w) => { OTBCraftMode.held = w; OTBVisor.render(); }, who);
283
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth), 0, `${who}: no sideways scroll`);
284
+ const [me] = await page.evaluate(box('.visor__me'));
285
+ const [main] = await page.evaluate(box('.visor__main'));
286
+ const hints = await page.evaluate(box('.visor__hint'));
287
+ const parts = [me, main, ...hints];
288
+ for (const p of parts) assert.ok(p.l >= 0 && p.r <= width + 0.5 && p.b <= height, `${who}: every part is inside the window`);
289
+ for (let i = 0; i < parts.length; i++) for (let j = i + 1; j < parts.length; j++) assert.ok(apart(parts[i], parts[j]), `${who}: parts ${i} and ${j} keep apart`);
290
+ for (const p of [main, ...hints]) assert.ok(p.w >= 24 && p.h >= 24, `${who}: a control is at least 24px both ways (${Math.round(p.w)}x${Math.round(p.h)})`);
291
+ assert.equal(await page.evaluate(() => document.querySelector('[data-visor-sheet]').getClientRects().length), 1, `${who}: Actions is on the glass at every width`);
292
+ if (width <= 640) {
293
+ assert.equal(hints.length, 1, `${who}: a phone keeps only Actions of the hints`);
294
+ assert.equal(await page.evaluate(() => document.querySelectorAll('.visor__place, .visor__frame--top, .visor__notch').length), 3);
295
+ assert.equal(await page.evaluate(() => [...document.querySelectorAll('.visor__place, .visor__frame--top, .visor__notch')].filter((e) => e.getClientRects().length).length), 0, `${who}: the top of a phone is the scene's`);
296
+ assert.ok(height - main.b >= 52 - 0.5 && height - main.b < 76, `${who}: the foot row sits lower on a phone`);
297
+ } else {
298
+ const [place] = await page.evaluate(box('.visor__place'));
299
+ const [arc] = await page.evaluate(box('.visor__frame--top'));
300
+ assert.ok(place && Math.abs((place.l + place.r) / 2 - width / 2) < 1, `${who}: the place is centred under the top arc`);
301
+ assert.ok(arc.t >= 0 && place.b <= 64, `${who}: the arc and the place keep inside the top inset, above a scene's own top row`);
302
+ }
303
+ await page.click('[data-visor-sheet]');
304
+ const [sheet] = await page.evaluate(box('.visor__sheet'));
305
+ assert.ok(sheet.l >= 0 && sheet.r <= width && sheet.t >= 0 && sheet.b <= Math.min(...parts.map((p) => p.t)) + 0.5, `${who}: the sheet stands inside the window, over the foot row`);
306
+ assert.deepEqual(errors, []);
307
+ await ctx.close();
308
+ }
309
+ });
310
+ }
311
+
312
+ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the name voice, the inset focus ring', { skip }, async () => {
313
+ const { ctx, page } = await open({ helm: true, width: 1440, height: 900 });
314
+ const g = await page.evaluate(() => {
315
+ const cs = (s) => getComputedStyle(document.querySelector(s));
316
+ const root = getComputedStyle(document.documentElement);
317
+ const probe = (v) => { const d = document.createElement('i'); d.style.color = v; document.body.appendChild(d); const c = getComputedStyle(d).color; d.remove(); return c; };
318
+ return {
319
+ mainBg: cs('.visor__main').backgroundColor, accent: probe('var(--accent)'), mainClip: cs('.visor__main').clipPath,
320
+ mainCase: cs('.visor__main').textTransform, hintBg: cs('.visor__hint').backgroundColor, hintBorder: cs('.visor__hint').borderTopWidth,
321
+ hintColor: cs('.visor__hint').color, ice: probe('var(--ink-soft)'), num: cs('.visor__num').color, unit: cs('.visor__unit').color, white: probe('var(--ink)'),
322
+ placeShadow: cs('.visor__place').textShadow, radius: cs('.visor__sprite').borderTopLeftRadius, spriteClip: cs('.visor__sprite').clipPath,
323
+ cut: root.getPropertyValue('--cut-path').trim() !== '',
324
+ };
325
+ });
326
+ assert.equal(g.mainBg, g.accent, 'the one accent fill is the button you press');
327
+ assert.match(g.mainClip, /^polygon\(/, 'cut corner');
328
+ assert.equal(g.mainCase, 'uppercase', 'the name voice');
329
+ assert.equal(g.hintBg, 'rgba(0, 0, 0, 0)');
330
+ assert.equal(g.hintBorder, '0px', 'a hint has no box');
331
+ assert.equal(g.hintColor, g.ice, 'hints are ice');
332
+ assert.equal(g.num, g.white, 'the count is white');
333
+ assert.equal(g.unit, g.ice, 'its word is ice');
334
+ assert.notEqual(g.placeShadow, 'none', 'type over the scene wears the halo');
335
+ assert.equal(g.radius, '0px', 'no round thing on the glass');
336
+ assert.match(g.spriteClip, /^polygon\(/, 'the sprite sits in a cut box');
337
+ await page.keyboard.press('Tab');
338
+ const f = await page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
339
+ assert.equal(f.cls, 'visor__main', 'the accent button is the first stop');
340
+ assert.equal(f.outline, 'none');
341
+ assert.match(f.ring, /inset/, 'focus is the inset two-tone ring (--press-focus)');
342
+ await ctx.close();
343
+ });
@@ -47,3 +47,37 @@ test('the rule catches a lone GNU or BSD stat, and passes the paired form and pr
47
47
  assert.equal(offending('U=$(stat -c %U "$d" 2>/dev/null || stat -f %Su "$d" 2>/dev/null)').length, 0);
48
48
  assert.equal(offending(" # was stat -c '%s', which BSD refuses").length, 0, 'a comment is not a call');
49
49
  });
50
+
51
+ // --- `head -n -K` — the same bug, a different GNU extension (task 1005096) ------------
52
+ //
53
+ // `head -n -K` means "all but the last K" to GNU and is an ERROR to BSD/macOS head
54
+ // ("illegal line count", exit 1). wedge-remedy.js's disk-full prune used it, so on a Mac
55
+ // the pipeline deleted nothing and the test that runs that command for real could never
56
+ // pass. The command itself runs on a Linux box — but a builder builds wherever they are,
57
+ // so a one-dialect idiom still reds their suite. POSIX awk answers it everywhere:
58
+ // … | sort | awk -v k=K '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}'
59
+ //
60
+ // Scanned in the .js files too, not only *.sh: this one lived inside a shell string a
61
+ // Node script builds, which the stat scan above would never have seen.
62
+ const GNU_HEAD = /(^|[^\w-])head\s+(-n\s*-\d|--lines[\s=]-\d|-\s*-\d)/;
63
+
64
+ test('no tracked script asks for `head -n -K`, which BSD/macOS head refuses', () => {
65
+ const files = execFileSync('git', ['ls-files', '*.sh', 'scripts/*.js', 'scripts/**/*.js'], { cwd: ROOT, encoding: 'utf8' })
66
+ .split('\n').filter(Boolean);
67
+ assert.ok(files.length > 20, `the scan must see the scripts, got ${files.length}`);
68
+ const hits = files.flatMap((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')
69
+ .split('\n').map((l, i) => ({ n: i + 1, l }))
70
+ .filter(({ l }) => !/^\s*(#|\/\/)/.test(l) && GNU_HEAD.test(l))
71
+ .map(({ n, l }) => `${f}:${n}: ${l.trim()}`));
72
+ assert.deepEqual(hits, [], "all but the last K: sort | awk -v k=K '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}'");
73
+ });
74
+
75
+ test('the head rule catches the GNU forms and leaves an ordinary head alone', () => {
76
+ const hit = (s) => GNU_HEAD.test(s);
77
+ assert.equal(hit("… | head -n -7 | xargs rm -f --"), true);
78
+ assert.equal(hit('… | head -n-7'), true);
79
+ assert.equal(hit('… | head --lines=-7'), true);
80
+ assert.equal(hit('… | head -n 7'), false, 'the ordinary form is portable');
81
+ assert.equal(hit('… | head -7'), false);
82
+ assert.equal(hit('git log | head -n 20'), false);
83
+ });
@@ -76,7 +76,11 @@ function writeFile(p, body) {
76
76
 
77
77
  // Build one fixture. `mainAt`: 'primary' | 'linked' | 'none'.
78
78
  function buildFixture(mainAt) {
79
- const root = fs.mkdtempSync(path.join(os.tmpdir(), `bongos-vendored-${mainAt}-`));
79
+ // realpath'd (task 1005096): on a Mac os.tmpdir() is /var/folders/…, a symlink to
80
+ // /private/var/folders/…, and resolveRoots answers with git's own answer — the /private
81
+ // form. Comparing the two spellings failed on every Mac and passed on CI's Linux, which
82
+ // has no such link. The fixture names the path the code under test will name.
83
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `bongos-vendored-${mainAt}-`)));
80
84
  const origin = path.join(root, 'origin.git');
81
85
  const inst = path.join(root, 'instance');
82
86
  git(root, 'init', '--bare', '-b', 'main', origin);
@@ -14,7 +14,7 @@ import { test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
15
  import { createRequire } from 'node:module';
16
16
  import { spawnSync } from 'node:child_process';
17
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
17
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
@@ -23,6 +23,12 @@ const require = createRequire(import.meta.url);
23
23
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
24
  const SWEEP = path.join(ROOT, 'scripts', 'bongos', 'update-sweep.js');
25
25
  const sweep = require(SWEEP);
26
+
27
+ // A temp folder the code under test and this test can BOTH name the same way (task 1005096).
28
+ // On a Mac os.tmpdir() is /var/folders/…, a symlink to /private/var/folders/…: anything that
29
+ // resolves a real path — as the sweep's resolver and git both do — answers the /private form,
30
+ // and the comparison failed on every Mac while passing on CI's Linux, which has no such link.
31
+ const tmpRoot = (prefix) => realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
26
32
  const { isPublishable } = require('../scripts/bongos/publish-manifest.js');
27
33
 
28
34
  test('the sweep and every core file it requires are in the published package', () => {
@@ -50,7 +56,7 @@ test('its target-instance resolver searches the same packages, in the same order
50
56
  });
51
57
 
52
58
  test('coreModulePathFrom finds a target instance\'s INSTALLED core, root first', () => {
53
- const inst = mkdtempSync(path.join(tmpdir(), 'sweep-home-'));
59
+ const inst = tmpRoot('sweep-home-');
54
60
  const pkg = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'bongos');
55
61
  mkdirSync(pkg, { recursive: true });
56
62
  writeFileSync(path.join(pkg, 'upgrade.js'), '');
@@ -62,11 +68,11 @@ test('coreModulePathFrom finds a target instance\'s INSTALLED core, root first',
62
68
  });
63
69
 
64
70
  test('run from elsewhere, it reads the roster named by --instance and says which core is sweeping', () => {
65
- const rosterRoot = mkdtempSync(path.join(tmpdir(), 'sweep-roster-'));
71
+ const rosterRoot = tmpRoot('sweep-roster-');
66
72
  mkdirSync(path.join(rosterRoot, 'config'));
67
73
  writeFileSync(path.join(rosterRoot, 'config', 'update-subscriptions.json'),
68
74
  JSON.stringify({ instances: [{ slug: 'held', dir: path.join(rosterRoot, 'nowhere'), channel: 'pinned' }] }));
69
- const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
75
+ const cwd = tmpRoot('sweep-cwd-');
70
76
  const r = spawnSync(process.execPath, [SWEEP, '--instance', rosterRoot], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
71
77
  assert.equal(r.status, 0, r.stderr);
72
78
  assert.match(r.stdout, new RegExp(`update-sweep · running from .+ \\(core \\d+\\.\\d+\\.\\d+\\) · roster ${rosterRoot.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}`));
@@ -75,7 +81,7 @@ test('run from elsewhere, it reads the roster named by --instance and says which
75
81
  });
76
82
 
77
83
  test('without --instance it reads the roster in its working directory (the unit\'s WorkingDirectory)', () => {
78
- const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
84
+ const cwd = tmpRoot('sweep-cwd-');
79
85
  const r = spawnSync(process.execPath, [SWEEP], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
80
86
  assert.equal(r.status, 0, r.stderr);
81
87
  assert.match(r.stdout, /no roster file — nothing subscribed/);
@@ -75,7 +75,11 @@ await t('a cloud-host project is fixed in ITS folder, not the control plane\'s',
75
75
  await t('disk full: prunes only THIS database\'s dumps, keeping the newest week', () => {
76
76
  const p = W.planRemedy(inst(), 'disk_full', { selfRoot, backupDir: '/var/backups/gds' });
77
77
  assert.match(p.cmd, /^find \/var\/backups\/gds -maxdepth 1 -type f -name 'charter-\[0-9\]/);
78
- assert.match(p.cmd, new RegExp(`head -n -${W.DUMP_KEEP_FLOOR} \\| xargs -r rm -f --$`));
78
+ // All-but-the-last-K in POSIX awk, never `head -n -K`: that is a GNU extension BSD/macOS
79
+ // head refuses outright, so the pipeline deleted nothing on a Mac (task 1005096).
80
+ assert.match(p.cmd, new RegExp(`awk -v k=${W.DUMP_KEEP_FLOOR} `));
81
+ assert.match(p.cmd, /\| xargs rm -f --$/);
82
+ assert.doesNotMatch(p.cmd, /head -n -/, 'GNU-only; BSD head answers "illegal line count"');
79
83
  assert.equal(W.DUMP_KEEP_FLOOR, 7);
80
84
  });
81
85