hop2 0.11.84 → 0.11.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.
@@ -362,8 +362,8 @@ const PHONE_ICONS: Record<string, string> = {
362
362
  moon: "M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z",
363
363
  gear: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"
364
364
  };
365
- const PhoneIcon = ({ name }: { name: string }) => (
366
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
365
+ const PhoneIcon = ({ name, size = 20 }: { name: string; size?: number }) => (
366
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
367
367
  <path d={PHONE_ICONS[name] || ""} />
368
368
  </svg>
369
369
  );
@@ -3703,14 +3703,14 @@ export const SessionSwitcher = ({
3703
3703
  {onOpenDesktop && (
3704
3704
  <button type="button" className="switcher-action views-entry" aria-label="Remote desktop"
3705
3705
  title="This machine's screen, through hop" onClick={() => onOpenDesktop()}>
3706
- <span aria-hidden="true">🖥</span>
3706
+ <span className="switcher-action-icon"><PhoneIcon name="display" size={15} /></span>
3707
3707
  Desktop
3708
3708
  </button>
3709
3709
  )}
3710
3710
  {onOpenCheckbacks && scheduledTotal > 0 && (
3711
3711
  <button type="button" className="switcher-action views-entry" aria-label="Scheduled on every session"
3712
3712
  title="Every check-back hop will type, across your sessions" onClick={() => onOpenCheckbacks("*")}>
3713
- <span aria-hidden="true">⏰</span>
3713
+ <span className="switcher-action-icon"><PhoneIcon name="clock" size={15} /></span>
3714
3714
  Scheduled
3715
3715
  <span className="views-entry-count">{scheduledTotal}</span>
3716
3716
  </button>
@@ -481,9 +481,10 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
481
481
  onMouseLeave={() => setArmedDelete((a) => (a === item.path ? null : a))}
482
482
  >
483
483
  {isImage(item.name)
484
- // The file is its own thumbnail — one lazy request, and a list of
485
- // plots becomes scannable at a glance.
486
- ? <img className="views-thumb" src={item.path} loading="lazy" alt="" />
484
+ // A small server-made thumbnail (the daemon falls back to the file
485
+ // itself): a list of plots scannable at a glance, without pulling
486
+ // every full image through the tunnel to draw it.
487
+ ? <img className="views-thumb" src={item.path.replace(/\/inline$/, "/thumb")} loading="lazy" decoding="async" alt="" />
487
488
  : <span className="views-kind" aria-hidden="true">{kindOf(item.name, item.kind)}</span>}
488
489
  <span className="views-row-text">
489
490
  <span className="views-title">{item.title || item.name}</span>
@@ -5824,3 +5824,5 @@ body.pane-dragging { user-select: none; }
5824
5824
  color: var(--accent);
5825
5825
  cursor: pointer;
5826
5826
  }
5827
+ /* Line icons in the wall's header buttons sit on the text like its glyphs. */
5828
+ .switcher-action-icon { display: inline-flex; align-items: center; opacity: 0.85; }
package/hop CHANGED
@@ -2746,6 +2746,60 @@ function saveHopConfig(config) {
2746
2746
  writePrivateFile(HOP_CONFIG_FILE, JSON.stringify(config, null, 2));
2747
2747
  }
2748
2748
 
2749
+ // --- View thumbnails ---
2750
+ // Small JPEGs for the Views list, made once per file version with macOS's
2751
+ // sips (ImageMagick elsewhere, when installed) and kept under the hop home.
2752
+ // A few at a time: a list opening asks for a screenful at once. No tool, or
2753
+ // a failure → null, and the caller serves the file itself as before.
2754
+ const VIEW_THUMB_DIR = path.join(HOME_DIR, 'cache', 'view-thumbs');
2755
+ const VIEW_THUMB_PX = 160;
2756
+ const VIEW_THUMB_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic', '.tif', '.tiff', '.bmp']);
2757
+ const viewThumbQueue = [];
2758
+ const viewThumbWaiting = new Map();
2759
+ let viewThumbRunning = 0;
2760
+ let viewThumbTool;
2761
+ function findViewThumbTool() {
2762
+ if (viewThumbTool !== undefined) return viewThumbTool;
2763
+ const has = (bin) => { try { execFileSync('which', [bin], { stdio: 'ignore' }); return true; } catch (e) { return false; } };
2764
+ viewThumbTool = process.platform === 'darwin' && has('sips') ? 'sips' : has('magick') ? 'magick' : has('convert') ? 'convert' : null;
2765
+ return viewThumbTool;
2766
+ }
2767
+ function viewThumbnail(file, done) {
2768
+ const ext = path.extname(file).toLowerCase();
2769
+ const tool = VIEW_THUMB_EXTS.has(ext) ? findViewThumbTool() : null;
2770
+ if (!tool) { done(null); return; }
2771
+ let st;
2772
+ try { st = fs.statSync(file); } catch (e) { done(null); return; }
2773
+ const key = crypto.createHash('sha1').update(`${file}\0${st.size}\0${st.mtimeMs}`).digest('hex');
2774
+ const out = path.join(VIEW_THUMB_DIR, key + '.jpg');
2775
+ if (fs.existsSync(out)) { done(out); return; }
2776
+ if (viewThumbWaiting.has(out)) { viewThumbWaiting.get(out).push(done); return; }
2777
+ viewThumbWaiting.set(out, [done]);
2778
+ viewThumbQueue.push({ file, out, tool });
2779
+ pumpViewThumbs();
2780
+ }
2781
+ function pumpViewThumbs() {
2782
+ while (viewThumbRunning < 3 && viewThumbQueue.length) {
2783
+ const { file, out, tool } = viewThumbQueue.shift();
2784
+ viewThumbRunning++;
2785
+ try { fs.mkdirSync(VIEW_THUMB_DIR, { recursive: true }); } catch (e) { /* the write below fails */ }
2786
+ const tmp = `${out}.${process.pid}.tmp.jpg`;
2787
+ const args = tool === 'sips'
2788
+ ? ['-Z', String(VIEW_THUMB_PX), '-s', 'format', 'jpeg', '-s', 'formatOptions', '70', file, '--out', tmp]
2789
+ : (tool === 'magick' ? ['convert'] : []).concat([`${file}[0]`, '-thumbnail', `${VIEW_THUMB_PX}x${VIEW_THUMB_PX}>`, '-quality', '70', tmp]);
2790
+ execFile(tool === 'magick' ? 'magick' : tool, args, { timeout: 15000 }, (err) => {
2791
+ viewThumbRunning--;
2792
+ let result = null;
2793
+ if (!err) { try { fs.renameSync(tmp, out); result = out; } catch (e) { /* none */ } }
2794
+ try { fs.unlinkSync(tmp); } catch (e) { /* renamed or never made */ }
2795
+ const waiters = viewThumbWaiting.get(out) || [];
2796
+ viewThumbWaiting.delete(out);
2797
+ for (const w of waiters) { try { w(result); } catch (e) { /* the response went away */ } }
2798
+ pumpViewThumbs();
2799
+ });
2800
+ }
2801
+ }
2802
+
2749
2803
  // --- Remote desktop ---
2750
2804
  // hop carries a VNC server that is already on the machine (macOS Screen
2751
2805
  // Sharing, or x11vnc/wayvnc on Linux) through its own authenticated link:
@@ -7731,6 +7785,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
7731
7785
  if (refiled.length) log(`${dryRun ? 'Would re-file' : 'Re-filed'} ${refiled.length} session(s) into their folders from the identity snapshot.`, DIM);
7732
7786
  const snapshot = await listAllSessionEntriesRuntimeFirst();
7733
7787
  const live = new Set(snapshot.entries.filter((e) => e.live).map((e) => e.internalName));
7788
+ // The host cuts room ids to 32 characters, so a record filed under a
7789
+ // longer name (forks named after their source did this) belongs to the
7790
+ // room with the cut name; while that runs, the record is not a dead
7791
+ // session to bring back.
7792
+ const isLive = (name) => live.has(name) || (name.length > 32 && live.has(name.slice(0, 32)));
7734
7793
  // Targeted redo (`hop restore <name>...`): the named sessions are planned
7735
7794
  // even when live - a failed restore leaves a LIVE room (bare shell,
7736
7795
  // parked dialog), which is exactly what a fleet restore skips, so the
@@ -7787,14 +7846,14 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
7787
7846
  // angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
7788
7847
  if (closedAny) await new Promise((r) => setTimeout(r, 2000));
7789
7848
  }
7790
- const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !live.has(e.internalName));
7849
+ const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !isLive(e.internalName));
7791
7850
  // Primary source: the SessionStart-hook records. Add any recorded session that
7792
7851
  // isn't already live or covered by the store, so restore works even though the
7793
7852
  // workspace store is empty. restorePlanForSession() reads each record and turns
7794
7853
  // it into `claude --resume <sessionId>`.
7795
7854
  const known = new Set(candidates.map((e) => e.internalName));
7796
7855
  for (const rec of listClaudeSessionRecords()) {
7797
- if (live.has(rec.internalName) || known.has(rec.internalName)) continue;
7856
+ if (isLive(rec.internalName) || known.has(rec.internalName)) continue;
7798
7857
  known.add(rec.internalName);
7799
7858
  candidates.push({
7800
7859
  internalName: rec.internalName,
@@ -7812,7 +7871,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
7812
7871
  // record, so after a HARD crash (no graceful-shutdown buffers) the meta's cwd
7813
7872
  // is its only trace — reopen a fresh shell there.
7814
7873
  for (const meta of listClaudeSessionMetas()) {
7815
- if (live.has(meta.internalName) || known.has(meta.internalName)) continue;
7874
+ if (isLive(meta.internalName) || known.has(meta.internalName)) continue;
7816
7875
  if (!meta.cwd || typeof meta.cwd !== 'string') continue;
7817
7876
  known.add(meta.internalName);
7818
7877
  candidates.push({
@@ -12616,7 +12675,7 @@ happens until someone clicks it.
12616
12675
  // file name. The legacy /assets/view/<s>/<f> form redirects here, so
12617
12676
  // an edge-cached response is at worst a redirect, never the bytes.
12618
12677
  const viewMatch = (req.method === 'GET' || req.method === 'HEAD')
12619
- && parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download)?$/);
12678
+ && parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline|\/download|\/thumb)?$/);
12620
12679
  if (viewMatch) {
12621
12680
  // /download: the stored file itself, as an attachment — the
12622
12681
  // markdown an agent wrote rather than the page it renders into,
@@ -12632,6 +12691,23 @@ happens until someone clicks it.
12632
12691
  return;
12633
12692
  }
12634
12693
  const viewExt = path.extname(target).toLowerCase();
12694
+ // /thumb: a small JPEG for the Views list. The list used to load
12695
+ // every image whole as its own thumbnail — 39 MB through the
12696
+ // tunnel to draw one screen of rows, with the view you picked
12697
+ // queued behind it.
12698
+ if (viewMatch[3] === '/thumb') {
12699
+ viewThumbnail(target, (thumb) => {
12700
+ if (!thumb) {
12701
+ res.writeHead(302, { Location: `/view/${viewMatch[1]}/${viewMatch[2]}/inline` });
12702
+ res.end();
12703
+ return;
12704
+ }
12705
+ res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': fs.statSync(thumb).size, 'Cache-Control': 'private, max-age=86400' });
12706
+ if (req.method === 'HEAD') { res.end(); return; }
12707
+ fs.createReadStream(thumb).pipe(res);
12708
+ });
12709
+ return;
12710
+ }
12635
12711
  // ?download=1 — the FILE, not the page: the bytes the agent
12636
12712
  // published, under their real name, as an attachment. Rendered
12637
12713
  // kinds (md/csv/json) come back raw here, not as the HTML the
@@ -13317,7 +13393,12 @@ happens until someone clicks it.
13317
13393
  forkName = forkBase + suffix + n;
13318
13394
  n += 1;
13319
13395
  }
13320
- const forkInternal = toInternalSessionName(forkName);
13396
+ // An opaque id like any new session's, not the name: the
13397
+ // host cuts room ids to 32 characters, so a long name ran
13398
+ // as a shorter room than its records were filed under,
13399
+ // and a later `hop restore` took the record for a dead
13400
+ // session and listed it as restored.
13401
+ const forkInternal = mintSessionInternalName();
13321
13402
 
13322
13403
  let command;
13323
13404
  let kind = 'shell';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.84",
3
+ "version": "0.11.85",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  const fs = require("node:fs");
23
23
  const os = require("node:os");
24
24
  const path = require("node:path");
25
- const { spawn } = require("node:child_process");
25
+ const { spawn, execFileSync } = require("node:child_process");
26
26
 
27
27
  function turnsDir() {
28
28
  const home = process.env.HOP_HOME || path.join(os.homedir(), ".hop2");
@@ -63,6 +63,32 @@ function resolveHopSession(dir, rootId, envSession) {
63
63
  return owners.length === 1 ? owners[0] : envSession;
64
64
  }
65
65
 
66
+ // Codex's shared app-server daemon runs the notify of every thread it
67
+ // serves with ITS OWN environment — the session that happened to start it.
68
+ // One started inside one session filed another session's turns as its own,
69
+ // so that record named the other conversation and `hop recover` there
70
+ // brought the wrong one back. Under the daemon
71
+ // HOP_SESSION says nothing about the thread. Codex runs this hook through
72
+ // whatever notify chain is configured (Computer Use wraps it), so walk up
73
+ // to the first codex process and look at how it runs.
74
+ function underSharedCodexDaemon() {
75
+ let pid = process.ppid;
76
+ for (let i = 0; i < 8 && pid > 1; i++) {
77
+ let row = "";
78
+ let args = "";
79
+ try {
80
+ row = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
81
+ args = execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
82
+ } catch { return false; }
83
+ const m = /^(\d+)\s+(.*)$/.exec(row);
84
+ if (!m) return false;
85
+ // The executable itself, not any command line that mentions it.
86
+ if (path.basename(m[2]) === "codex") return /\sapp-server(\s|$)/.test(args);
87
+ pid = Number(m[1]);
88
+ }
89
+ return false;
90
+ }
91
+
66
92
  // A sub-agent's rollout names its parent in its first line (session_meta →
67
93
  // parent_thread_id). Walk up to the root, reading only that line of each
68
94
  // file — a long thread's rollout runs to hundreds of MB. Any failure leaves
@@ -114,7 +140,10 @@ function bumpTurn(payload) {
114
140
  : (payload && typeof payload.thread_id === "string") ? payload.thread_id
115
141
  : (payload && typeof payload.session_id === "string") ? payload.session_id : null;
116
142
  let rootId = rootThreadId(rawThread);
117
- const hopSession = resolveHopSession(dir, rootId, envSession);
143
+ // Under the shared daemon the environment is another session's: the
144
+ // turn goes only to a record that already names this thread, else nowhere.
145
+ const hopSession = underSharedCodexDaemon() ? resolveHopSession(dir, rootId, null) : resolveHopSession(dir, rootId, envSession);
146
+ if (!hopSession) return;
118
147
  const file = path.join(dir, `${hopSession}.turn`);
119
148
  let count = 0;
120
149
  let prev = null;
@@ -61,3 +61,40 @@ test('other event types count nothing', () => {
61
61
  run(home, 'alpha', { type: 'something-else', 'thread-id': 'thread-A' });
62
62
  assert.equal(turn(home, 'alpha'), null);
63
63
  });
64
+
65
+ // Codex's shared app-server daemon runs every thread's notify with the
66
+ // environment of whichever session started it. A parent whose command line
67
+ // says app-server stands in for it here.
68
+ // The daemon is an executable named codex running `app-server`: a copy of
69
+ // node under that name, running the hook, stands in for it.
70
+ const runAsDaemon = (home, session, json) => {
71
+ const bin = path.join(home, 'bin'); fs.mkdirSync(bin, { recursive: true });
72
+ const codex = path.join(bin, 'codex');
73
+ if (!fs.existsSync(codex)) fs.symlinkSync(process.execPath, codex);
74
+ const script = path.join(bin, 'daemon.js');
75
+ fs.writeFileSync(script, 'require("child_process").execFileSync(process.execPath, process.argv.slice(3), { stdio: "inherit" })');
76
+ return require('node:child_process').spawnSync(codex, [script, 'app-server', HOOK, json], {
77
+ env: { ...process.env, HOP_HOME: home, HOP_SESSION: session, CODEX_HOME: path.join(home, 'codex') }
78
+ });
79
+ };
80
+
81
+ test("under Codex's shared daemon a turn is not filed against the daemon's session", () => {
82
+ const home = freshHome();
83
+ saveRollout(home, 'thread-starter'); saveRollout(home, 'thread-other');
84
+ fs.writeFileSync(path.join(home, 'claude-sessions', 'starter.turn'), JSON.stringify({ sessionId: 'thread-starter', count: 4, at: '2026-10-14T00:00:00.000Z', agent: 'codex' }));
85
+ fs.writeFileSync(path.join(home, 'claude-sessions', 'other.turn'), JSON.stringify({ sessionId: 'thread-other', count: 7, at: '2026-10-14T00:00:00.000Z', agent: 'codex' }));
86
+ // Another session's thread finishes a turn inside the daemon the starter session started.
87
+ const daemon = runAsDaemon(home, 'starter', JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 'thread-other', 'turn-id': 't1' }));
88
+ assert.equal(daemon.status, 0, String(daemon.stderr));
89
+ assert.equal(turn(home, 'starter').sessionId, 'thread-starter', "the starter's record keeps its own thread");
90
+ assert.equal(turn(home, 'starter').count, 4, 'and is not counted');
91
+ assert.equal(turn(home, 'other').count, 8, 'the turn goes to the record that names the thread');
92
+ });
93
+
94
+ test('under the shared daemon a thread no record names is filed nowhere', () => {
95
+ const home = freshHome();
96
+ saveRollout(home, 'thread-new');
97
+ const daemon = runAsDaemon(home, 'starter', JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 'thread-new', 'turn-id': 't1' }));
98
+ assert.equal(daemon.status, 0, String(daemon.stderr));
99
+ assert.equal(turn(home, 'starter'), null, 'no record is invented for the daemon session');
100
+ });
@@ -1055,7 +1055,7 @@ test('a session whose display name has spaces can be renamed and forked', async
1055
1055
  internalName: internal
1056
1056
  });
1057
1057
  assert.equal(fork.status, 200, JSON.stringify(fork.data));
1058
- assert.match(fork.data.internalName, /^[A-Za-z0-9_-]+$/);
1058
+ assert.match(fork.data.internalName, /^s_[0-9a-f]{10}$/, 'a fork gets an opaque id, not its (possibly long) name');
1059
1059
 
1060
1060
  const rename = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
1061
1061
  oldName: 'Find grant opportunities', newName: 'grant-search', internalName: internal
@@ -1069,6 +1069,37 @@ test('a session whose display name has spaces can be renamed and forked', async
1069
1069
  });
1070
1070
 
1071
1071
 
1072
+ // The host cuts room ids to 32 characters. A record filed under a longer
1073
+ // name belongs to the room with the cut name, and while that room runs,
1074
+ // restore must not plan the record as a dead session.
1075
+ test('restore does not take a long-named record for dead while its cut room runs', async () => {
1076
+ const dir = path.join(hopHome, 'claude-sessions');
1077
+ await fs.mkdir(dir, { recursive: true });
1078
+ const cut = 'longnamerecordcheck-abcdefghijkl'; // 32 characters
1079
+ const long = cut + '-continued';
1080
+ assert.equal(cut.length, 32);
1081
+ await fs.writeFile(path.join(dir, `${cut}.meta`), JSON.stringify({ internalName: cut, cwd: tempDir }), { mode: 0o600 });
1082
+ const up = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { only: [cut] });
1083
+ assert.equal(up.status, 200, JSON.stringify(up.data));
1084
+ let running = false;
1085
+ for (let i = 0; i < 40 && !running; i++) {
1086
+ const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
1087
+ running = !!list.data.sessions.find((x) => x.internalName === cut && x.live !== false);
1088
+ if (!running) await delay(150);
1089
+ }
1090
+ assert.ok(running, 'the 32-character room is up');
1091
+
1092
+ await fs.writeFile(path.join(dir, `${long}.meta`), JSON.stringify({ internalName: long, displayName: long, cwd: tempDir }), { mode: 0o600 });
1093
+ const plan = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', { dryRun: true });
1094
+ assert.equal(plan.status, 200, JSON.stringify(plan.data));
1095
+ const names = (plan.data.restored || []).map((x) => x.name);
1096
+ assert.ok(!names.includes(long), `planned the live session again: ${names.join(', ')}`);
1097
+
1098
+ await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: cut, internalName: cut });
1099
+ await fs.rm(path.join(dir, `${long}.meta`), { force: true });
1100
+ });
1101
+
1102
+
1072
1103
  // A session continued from the Codex app has no turn record until its first
1073
1104
  // turn finishes in hop — and until then Continue-in-Claude refused it as a
1074
1105
  // plain shell. Its record names the app thread, and that thread's rollout
@@ -1526,3 +1557,45 @@ test('the desktop pipe is off by default and, once on, carries bytes both ways',
1526
1557
  vnc.close();
1527
1558
  }
1528
1559
  });
1560
+
1561
+ test('a view image has a small thumbnail; other kinds fall back to the file', async () => {
1562
+ const dir = path.join(hopHome, 'views', 'thumbprobe');
1563
+ await fs.mkdir(dir, { recursive: true });
1564
+ // A 400x300 PNG, written by hand: one solid-colour row repeated.
1565
+ const zlib = require('node:zlib');
1566
+ const w = 400, h = 300;
1567
+ const crc = (buf) => { let c = ~0; for (const b of buf) { c ^= b; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; };
1568
+ const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
1569
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
1570
+ const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(w * 3, 0x80)]);
1571
+ const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr),
1572
+ chunk('IDAT', zlib.deflateSync(Buffer.concat(Array(h).fill(row)))), chunk('IEND', Buffer.alloc(0))]);
1573
+ await fs.writeFile(path.join(dir, 'plot.png'), png);
1574
+ await fs.writeFile(path.join(dir, 'notes.pdf'), Buffer.alloc(64, 1));
1575
+ const get = (reqPath) => new Promise((resolve, reject) => {
1576
+ const req = http.request({ hostname: '127.0.0.1', port: state.port, path: reqPath, method: 'GET',
1577
+ headers: { Authorization: `Bearer ${state.sessionSecret}` } }, (res) => {
1578
+ const parts = []; res.on('data', (d) => parts.push(d));
1579
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(parts) }));
1580
+ });
1581
+ req.on('error', reject); req.end();
1582
+ });
1583
+ try {
1584
+ const thumb = await get('/view/thumbprobe/plot.png/thumb');
1585
+ if (thumb.status === 200) {
1586
+ assert.equal(thumb.headers['content-type'], 'image/jpeg');
1587
+ assert.ok(thumb.body.length < png.length, 'smaller than the image itself');
1588
+ const again = await get('/view/thumbprobe/plot.png/thumb');
1589
+ assert.deepEqual(again.body, thumb.body, 'served from the cache the second time');
1590
+ } else {
1591
+ // No image tool on this machine: the file itself, as before.
1592
+ assert.equal(thumb.status, 302);
1593
+ assert.equal(thumb.headers.location, '/view/thumbprobe/plot.png/inline');
1594
+ }
1595
+ const pdf = await get('/view/thumbprobe/notes.pdf/thumb');
1596
+ assert.equal(pdf.status, 302);
1597
+ assert.equal((await get('/view/thumbprobe/missing.png/thumb')).status, 404);
1598
+ } finally {
1599
+ await fs.rm(dir, { recursive: true, force: true });
1600
+ }
1601
+ });