File size: 5,549 Bytes
6741fc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
 * Screenshot a LedgerLens page once it has finished rendering real data.
 *
 * Drives headless Chrome over the DevTools Protocol using Node's built-in WebSocket,
 * so capturing the dashboard needs no Puppeteer or Playwright dependency. It waits on
 * a DOM condition rather than a fixed sleep: the dashboard polls the API and animates
 * its counters, so a timed capture reliably catches a page mid-count with zeros in
 * the KPI cards — a screenshot that misrepresents the product in the direction of
 * looking broken.
 *
 *   node scripts/shot.mjs <url> <out.png> [width] [height] [waitForText] [afterJs]
 *
 * `afterJs` runs once the wait condition holds and before the capture, for the shots
 * that are behind an interaction — opening the audit drawer, scrolling to the ledger.
 * Anything it needs to wait for it can await itself; its result is awaited here.
 */
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const [url, out, width = "1512", height = "1100", waitFor = "", afterJs = ""] =
  process.argv.slice(2);
if (!url || !out) {
  console.error("usage: node scripts/shot.mjs <url> <out.png> [w] [h] [waitForText]");
  process.exit(2);
}

const CHROME =
  process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const profile = mkdtempSync(join(tmpdir(), "ledgerlens-shot-"));
const port = 9200 + Math.floor(Math.random() * 400);

const chrome = spawn(
  CHROME,
  [
    "--headless=new",
    "--disable-gpu",
    "--no-sandbox",
    "--hide-scrollbars",
    "--no-first-run",
    "--force-color-profile=srgb",
    `--user-data-dir=${profile}`,
    `--remote-debugging-port=${port}`,
    `--window-size=${width},${height}`,
    "about:blank",
  ],
  { stdio: "ignore" },
);

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function target() {
  for (let i = 0; i < 60; i += 1) {
    try {
      const res = await fetch(`http://127.0.0.1:${port}/json/list`);
      const pages = (await res.json()).filter((t) => t.type === "page");
      if (pages.length) return pages[0].webSocketDebuggerUrl;
    } catch {
      /* chrome not up yet */
    }
    await sleep(250);
  }
  throw new Error("chrome did not expose a debugging target");
}

function connect(wsUrl) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    let id = 0;
    const pending = new Map();
    ws.addEventListener("message", (event) => {
      const msg = JSON.parse(event.data);
      const entry = pending.get(msg.id);
      if (!entry) return;
      pending.delete(msg.id);
      if (msg.error) entry.reject(new Error(JSON.stringify(msg.error)));
      else entry.resolve(msg.result);
    });
    ws.addEventListener("error", reject);
    ws.addEventListener("open", () =>
      resolve({
        send(method, params = {}) {
          id += 1;
          const messageId = id;
          return new Promise((res, rej) => {
            pending.set(messageId, { resolve: res, reject: rej });
            ws.send(JSON.stringify({ id: messageId, method, params }));
          });
        },
        close: () => ws.close(),
      }),
    );
  });
}

let timedOut = false;
try {
  const client = await connect(await target());
  await client.send("Page.enable");
  await client.send("Runtime.enable");
  await client.send("Page.navigate", { url });

  const deadline = Date.now() + 60_000;
  for (;;) {
    await sleep(400);
    // Case-insensitive: CSS text-transform is reflected in innerText, so a heading
    // styled uppercase would never match its source-cased string.
    const expression = waitFor
      ? `!!document.body && document.body.innerText.toLowerCase().includes(${JSON.stringify(
          waitFor.toLowerCase(),
        )})`
      : `document.readyState === "complete"`;
    const { result } = await client.send("Runtime.evaluate", {
      expression,
      returnByValue: true,
    });
    if (result.value === true) break;
    if (Date.now() > deadline) {
      timedOut = true;
      console.error(`timed out waiting for: ${waitFor || "load"}`);
      break;
    }
  }
  if (afterJs) {
    const { result, exceptionDetails } = await client.send("Runtime.evaluate", {
      expression: afterJs,
      awaitPromise: true,
      returnByValue: true,
    });
    if (exceptionDetails) {
      throw new Error(`afterJs threw: ${exceptionDetails.text} ${result?.description ?? ""}`);
    }
    if (result?.value !== undefined) console.error(`afterJs -> ${result.value}`);
  }

  // Let the counter animations and the entrance transition settle, so the capture
  // is the resting state rather than a frame partway through it.
  await sleep(1_800);

  const { data } = await client.send("Page.captureScreenshot", {
    format: "png",
    captureBeyondViewport: false,
  });
  writeFileSync(out, Buffer.from(data, "base64"));
  console.log(`wrote ${out}`);
  client.close();
} finally {
  chrome.kill("SIGKILL");
  // SIGKILL returns before the kernel has finished reaping Chrome, which is still
  // flushing its profile directory. Removing it in that window raises ENOTEMPTY and
  // fails the command after the screenshot was already written successfully.
  await sleep(400);
  rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}

// A screenshot taken after a timeout is a screenshot of the wrong thing, and it
// would be committed as if it were the right thing.
if (timedOut) process.exit(1);