@lotics/cli 0.70.0 → 0.73.0

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,303 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { parseAppPackageContract, validateAppPackageContract, } from "@lotics/shared/schemas/app_packages";
6
+ import { starterContract, readPackageProject, writePackageManifest, sanitizePackageJsonForSource, stagePackageSource, parseResolveFlags, assertDevWorkspace, draftPackageProjectFromApp, parseAdoptBindingFile, formatExtractReport, } from "./package_commands.js";
7
+ describe("starterContract", () => {
8
+ it("scaffolds a structurally + referentially valid contract", () => {
9
+ const raw = starterContract("Acme CRM");
10
+ // Structurally valid against the canonical contract schema.
11
+ const contract = parseAppPackageContract(raw);
12
+ // Cross-references (query from_entity, etc.) resolve — zero violations, so the
13
+ // scaffold publishes without the server's contract validator rejecting it.
14
+ expect(validateAppPackageContract(contract)).toEqual([]);
15
+ });
16
+ it("uses the package name as the entity label", () => {
17
+ const contract = parseAppPackageContract(starterContract("Widgets"));
18
+ expect(contract.entities[0].label).toBe("Widgets");
19
+ expect(contract.config[0].default).toBe("Widgets");
20
+ });
21
+ });
22
+ describe("package manifest round-trip", () => {
23
+ function makeProject() {
24
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
25
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
26
+ name: "acme-crm",
27
+ version: "0.0.1",
28
+ lotics: {
29
+ package: { id: null, name: "Acme CRM", description: null, version: null, dev: {} },
30
+ },
31
+ }, null, 2) + "\n");
32
+ return dir;
33
+ }
34
+ it("reads the package manifest off package.json#lotics.package", () => {
35
+ const dir = makeProject();
36
+ try {
37
+ const { manifest } = readPackageProject(dir);
38
+ expect(manifest).toEqual({
39
+ id: null,
40
+ name: "Acme CRM",
41
+ description: null,
42
+ version: null,
43
+ dev: {},
44
+ });
45
+ }
46
+ finally {
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+ it("persists publish + dev-installation pins and reads them back", () => {
51
+ const dir = makeProject();
52
+ try {
53
+ const project = readPackageProject(dir);
54
+ project.manifest.id = "apg_test";
55
+ project.manifest.version = 3;
56
+ project.manifest.dev["wsp_dev"] = { app_id: "app_dev", version: 3 };
57
+ writePackageManifest(dir, project);
58
+ const reread = readPackageProject(dir);
59
+ expect(reread.manifest.id).toBe("apg_test");
60
+ expect(reread.manifest.version).toBe(3);
61
+ expect(reread.manifest.dev).toEqual({ wsp_dev: { app_id: "app_dev", version: 3 } });
62
+ // Non-lotics package.json fields survive the manifest write.
63
+ expect(reread.pkgJson.name).toBe("acme-crm");
64
+ expect(reread.pkgJson.version).toBe("0.0.1");
65
+ }
66
+ finally {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });
70
+ it("writes the manifest atomically, leaving no temp file behind", () => {
71
+ const dir = makeProject();
72
+ try {
73
+ const project = readPackageProject(dir);
74
+ project.manifest.id = "apg_atomic";
75
+ writePackageManifest(dir, project);
76
+ // The atomic write renames a temp file into place; nothing stray remains.
77
+ const leftovers = fs.readdirSync(dir).filter((f) => f.startsWith("package.json."));
78
+ expect(leftovers).toEqual([]);
79
+ expect(readPackageProject(dir).manifest.id).toBe("apg_atomic");
80
+ }
81
+ finally {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+ });
86
+ describe("sanitizePackageJsonForSource", () => {
87
+ it("strips the author-local lotics.package.dev map", () => {
88
+ const sanitized = sanitizePackageJsonForSource({
89
+ name: "acme-crm",
90
+ version: "0.0.1",
91
+ lotics: {
92
+ package: {
93
+ id: "apg_x",
94
+ name: "Acme CRM",
95
+ description: "d",
96
+ version: 4,
97
+ dev: { wsp_dev: { app_id: "app_dev", version: 4 } },
98
+ },
99
+ },
100
+ });
101
+ const lotics = sanitized.lotics;
102
+ // The private dev bookkeeping is gone…
103
+ expect("dev" in lotics.package).toBe(false);
104
+ // …while the package identity + other fields survive untouched.
105
+ expect(lotics.package).toEqual({
106
+ id: "apg_x",
107
+ name: "Acme CRM",
108
+ description: "d",
109
+ version: 4,
110
+ });
111
+ expect(sanitized.name).toBe("acme-crm");
112
+ expect(sanitized.version).toBe("0.0.1");
113
+ });
114
+ it("does not mutate the input package.json", () => {
115
+ const input = {
116
+ name: "acme-crm",
117
+ lotics: { package: { id: "apg_x", name: "Acme CRM", dev: { wsp_dev: { app_id: "a", version: 1 } } } },
118
+ };
119
+ sanitizePackageJsonForSource(input);
120
+ expect(input.lotics.package.dev).toEqual({ wsp_dev: { app_id: "a", version: 1 } });
121
+ });
122
+ });
123
+ describe("assertDevWorkspace", () => {
124
+ it("passes a dev workspace", () => {
125
+ expect(() => assertDevWorkspace({ id: "wsp_d", name: "Dev", is_dev: true })).not.toThrow();
126
+ });
127
+ it("fails loud on a non-dev workspace", () => {
128
+ expect(() => assertDevWorkspace({ id: "wsp_p", name: "Prod", is_dev: false })).toThrow(/not a dev workspace/);
129
+ });
130
+ it("fails closed when is_dev is absent (server doesn't serialize it)", () => {
131
+ expect(() => assertDevWorkspace({ id: "wsp_u", name: "Unknown" })).toThrow(/not a dev workspace/);
132
+ });
133
+ it("rejects a directory that is not a package project", () => {
134
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
135
+ try {
136
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "plain-app" }));
137
+ expect(() => readPackageProject(dir)).toThrow(/not a package project/);
138
+ }
139
+ finally {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ });
143
+ });
144
+ describe("parseResolveFlags", () => {
145
+ it("maps 'recreate' and ids to their resolution shapes", () => {
146
+ expect(parseResolveFlags(["fields.deal.stage=recreate", "templates.quote=dtl_abc"])).toEqual({
147
+ "fields.deal.stage": "recreate",
148
+ "templates.quote": { bind_to: "dtl_abc" },
149
+ });
150
+ });
151
+ it("maps modified-core consents 'revert' and 'keep' as literals", () => {
152
+ expect(parseResolveFlags(["queries.tasks=revert", "workflows.notify=keep"])).toEqual({
153
+ "queries.tasks": "revert",
154
+ "workflows.notify": "keep",
155
+ });
156
+ });
157
+ it("rejects entries without a key=value shape", () => {
158
+ expect(() => parseResolveFlags(["fields.deal.stage"])).toThrow(/Invalid --resolve/);
159
+ expect(() => parseResolveFlags(["=recreate"])).toThrow(/Invalid --resolve/);
160
+ expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
161
+ });
162
+ });
163
+ describe("draftPackageProjectFromApp", () => {
164
+ it("strips the app manifest and grafts an unpublished package manifest", () => {
165
+ const project = draftPackageProjectFromApp({
166
+ name: "acme-crm",
167
+ version: "0.0.1",
168
+ dependencies: { "@lotics/app-sdk": "^1.0.0" },
169
+ lotics: {
170
+ app_id: "app_x",
171
+ workspace_id: "wsp_y",
172
+ current_version_id: "apv_z",
173
+ workflows: { notify: { workflow_id: "wfl_1" } },
174
+ },
175
+ }, { name: "Acme CRM", description: null });
176
+ // The app manifest (app_id/workspace_id/workflows) is gone entirely…
177
+ expect("lotics" in project.pkgJson).toBe(false);
178
+ // …the package manifest is fresh + unpublished…
179
+ expect(project.manifest).toEqual({
180
+ id: null,
181
+ name: "Acme CRM",
182
+ description: null,
183
+ version: null,
184
+ dev: {},
185
+ });
186
+ // …and non-lotics fields survive verbatim.
187
+ expect(project.pkgJson.name).toBe("acme-crm");
188
+ expect(project.pkgJson.version).toBe("0.0.1");
189
+ expect(project.pkgJson.dependencies).toEqual({ "@lotics/app-sdk": "^1.0.0" });
190
+ });
191
+ it("round-trips through writePackageManifest to a valid package project on disk", () => {
192
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
193
+ try {
194
+ writePackageManifest(dir, draftPackageProjectFromApp({ name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } }, { name: "Acme CRM", description: "A CRM" }));
195
+ // readPackageProject only accepts a real package project (lotics.package).
196
+ const { manifest, pkgJson } = readPackageProject(dir);
197
+ expect(manifest).toEqual({
198
+ id: null,
199
+ name: "Acme CRM",
200
+ description: "A CRM",
201
+ version: null,
202
+ dev: {},
203
+ });
204
+ // The old app manifest keys never made it to disk.
205
+ const lotics = pkgJson.lotics;
206
+ expect("app_id" in lotics).toBe(false);
207
+ expect("workspace_id" in lotics).toBe(false);
208
+ }
209
+ finally {
210
+ fs.rmSync(dir, { recursive: true, force: true });
211
+ }
212
+ });
213
+ it("does not mutate the input package.json", () => {
214
+ const input = { name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } };
215
+ draftPackageProjectFromApp(input, { name: "Acme CRM", description: null });
216
+ expect(input.lotics).toEqual({ app_id: "app_x", workspace_id: "wsp_y" });
217
+ });
218
+ });
219
+ describe("parseAdoptBindingFile", () => {
220
+ const validPin = {
221
+ app_id: "app_x",
222
+ workspace_id: "wsp_y",
223
+ binding: { entities: { item: "tbl_1" }, fields: {}, options: {}, templates: {}, roles: {}, workflows: {} },
224
+ };
225
+ it("accepts a pin whose app_id matches the app being adopted", () => {
226
+ const pin = parseAdoptBindingFile(validPin, "app_x");
227
+ expect(pin.app_id).toBe("app_x");
228
+ expect(pin.workspace_id).toBe("wsp_y");
229
+ expect(pin.binding.entities).toEqual({ item: "tbl_1" });
230
+ });
231
+ it("REFUSES a pin recorded for a different app", () => {
232
+ expect(() => parseAdoptBindingFile(validPin, "app_OTHER")).toThrow(/records app app_x, but you are adopting app_OTHER/);
233
+ });
234
+ it("rejects a malformed pin", () => {
235
+ expect(() => parseAdoptBindingFile({ app_id: "app_x" }, "app_x")).toThrow(/Malformed/);
236
+ expect(() => parseAdoptBindingFile(null, "app_x")).toThrow(/Malformed/);
237
+ expect(() => parseAdoptBindingFile({ app_id: "app_x", workspace_id: "wsp_y" }, "app_x")).toThrow(/Malformed/);
238
+ });
239
+ });
240
+ describe("formatExtractReport", () => {
241
+ it("groups findings errors → warnings → info and formats each line", () => {
242
+ const { lines, hasError } = formatExtractReport([
243
+ { severity: "info", area: "queries.items", message: "inverted cleanly" },
244
+ { severity: "error", area: "fields.deal.stage", message: "button field is not portable" },
245
+ { severity: "warning", area: "workflows.notify", message: "legacy key retained" },
246
+ { severity: "error", area: "queries.deals", message: "round-trip mismatch" },
247
+ ]);
248
+ expect(lines).toEqual([
249
+ " [error] fields.deal.stage: button field is not portable",
250
+ " [error] queries.deals: round-trip mismatch",
251
+ " [warning] workflows.notify: legacy key retained",
252
+ " [info] queries.items: inverted cleanly",
253
+ ]);
254
+ expect(hasError).toBe(true);
255
+ });
256
+ it("reports no error when only warnings/info are present", () => {
257
+ const { lines, hasError } = formatExtractReport([
258
+ { severity: "warning", area: "a", message: "w" },
259
+ { severity: "info", area: "b", message: "i" },
260
+ ]);
261
+ expect(lines).toEqual([" [warning] a: w", " [info] b: i"]);
262
+ expect(hasError).toBe(false);
263
+ });
264
+ it("handles an empty report", () => {
265
+ expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
266
+ });
267
+ });
268
+ describe("stagePackageSource", () => {
269
+ it("keeps nested dist-named dirs, drops top-level excludes, sanitizes package.json", () => {
270
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-test-"));
271
+ const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-out-"));
272
+ try {
273
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
274
+ name: "pkg",
275
+ lotics: { package: { id: "apg_x", name: "pkg", version: 3, dev: { wsp_a: "app_1" } } },
276
+ }));
277
+ fs.mkdirSync(path.join(dir, "src"));
278
+ fs.writeFileSync(path.join(dir, "src", "main.tsx"), "export {}");
279
+ // A nested dir NAMED dist must ship — only the top-level dist is a build output.
280
+ fs.mkdirSync(path.join(dir, "templates", "dist"), { recursive: true });
281
+ fs.writeFileSync(path.join(dir, "templates", "dist", "quote.xlsx"), "bytes");
282
+ fs.mkdirSync(path.join(dir, "dist"));
283
+ fs.writeFileSync(path.join(dir, "dist", "index.js"), "built");
284
+ fs.mkdirSync(path.join(dir, "node_modules", "x"), { recursive: true });
285
+ fs.writeFileSync(path.join(dir, "node_modules", "x", "i.js"), "dep");
286
+ fs.writeFileSync(path.join(dir, "tsconfig.tsbuildinfo"), "{}");
287
+ stagePackageSource(dir, stage);
288
+ expect(fs.existsSync(path.join(stage, "src", "main.tsx"))).toBe(true);
289
+ expect(fs.existsSync(path.join(stage, "templates", "dist", "quote.xlsx"))).toBe(true);
290
+ expect(fs.existsSync(path.join(stage, "dist"))).toBe(false);
291
+ expect(fs.existsSync(path.join(stage, "node_modules"))).toBe(false);
292
+ expect(fs.existsSync(path.join(stage, "tsconfig.tsbuildinfo"))).toBe(false);
293
+ // The staged package.json is the sanitized one — dev pins stripped.
294
+ const staged = JSON.parse(fs.readFileSync(path.join(stage, "package.json"), "utf-8"));
295
+ expect(staged.lotics.package.dev).toBeUndefined();
296
+ expect(staged.lotics.package.id).toBe("apg_x");
297
+ }
298
+ finally {
299
+ fs.rmSync(dir, { recursive: true, force: true });
300
+ fs.rmSync(stage, { recursive: true, force: true });
301
+ }
302
+ });
303
+ });
@@ -0,0 +1,3 @@
1
+ export declare function runPreviewCommand(filePath: string | undefined, flags: {
2
+ output?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,233 @@
1
+ // `lotics preview <file.docx|.xlsx> [--out x.png]` — render a generated document to
2
+ // a PNG using the SAME engines the frontend FilePreview uses. No npm deps: drives a
3
+ // headless Chrome over CDP with Node built-ins (WebSocket/fetch/http/child_process),
4
+ // keeping the CLI a single bundled binary. The render logic lives in the esbuild
5
+ // browser bundle dist/render_page.js (built by build_cli.mjs); this file orchestrates
6
+ // Chrome. Chrome itself is external (system Chrome / Playwright chromium / CHROME_PATH)
7
+ // — inherent to rendering docx/xlsx, which are browser-rendered formats.
8
+ import { spawn } from "node:child_process";
9
+ import { createServer } from "node:http";
10
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, readdirSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, dirname, resolve, extname, basename } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ function fail(msg) {
17
+ console.error(msg);
18
+ process.exit(1);
19
+ }
20
+ /** Locate a Chrome/Chromium binary: explicit env → Playwright's install → system. */
21
+ function findChrome() {
22
+ const env = process.env.LOTICS_CHROME || process.env.CHROME_PATH;
23
+ if (env && existsSync(env))
24
+ return env;
25
+ const home = process.env.HOME || "";
26
+ // Playwright installs under ms-playwright/chromium-<rev>/… Enumerate with readdirSync
27
+ // (Node 10+) — NOT node:fs globSync, which is Node 22+; a globSync import would fail to
28
+ // load the whole bundled CLI on the Node 18+ it supports.
29
+ const pwDir = process.platform === "darwin"
30
+ ? `${home}/Library/Caches/ms-playwright`
31
+ : `${home}/.cache/ms-playwright`;
32
+ if (existsSync(pwDir)) {
33
+ const rel = process.platform === "darwin"
34
+ ? ["chrome-mac/Chromium.app/Contents/MacOS/Chromium"]
35
+ : ["chrome-linux/chrome", "chrome-linux/headless_shell"];
36
+ const revs = readdirSync(pwDir)
37
+ .filter((n) => n.startsWith("chromium-") || n.startsWith("chromium_headless_shell-"))
38
+ .sort()
39
+ .reverse();
40
+ for (const rev of revs) {
41
+ for (const r of rel) {
42
+ const bin = join(pwDir, rev, r);
43
+ if (existsSync(bin))
44
+ return bin;
45
+ }
46
+ }
47
+ }
48
+ const systemPaths = [
49
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
50
+ "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
51
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
52
+ ];
53
+ return systemPaths.find((p) => existsSync(p)) ?? null;
54
+ }
55
+ /** Minimal CDP client over the built-in WebSocket. */
56
+ async function cdpConnect(wsUrl) {
57
+ const ws = new WebSocket(wsUrl);
58
+ await new Promise((res, rej) => {
59
+ ws.onopen = () => res();
60
+ ws.onerror = () => rej(new Error("CDP websocket failed to open"));
61
+ });
62
+ let id = 0;
63
+ const pending = new Map();
64
+ // If Chrome exits mid-render the socket closes with no reply — settle every in-flight
65
+ // request so `send()` rejects instead of hanging forever (the finally then cleans up).
66
+ ws.onclose = () => {
67
+ for (const done of pending.values())
68
+ done(Promise.reject(new Error("CDP connection closed (Chrome exited?)")));
69
+ pending.clear();
70
+ };
71
+ ws.onmessage = (e) => {
72
+ const m = JSON.parse(String(e.data));
73
+ if (m.id != null && pending.has(m.id)) {
74
+ const done = pending.get(m.id);
75
+ pending.delete(m.id);
76
+ done(m.error ? Promise.reject(new Error(m.error.message)) : m.result);
77
+ }
78
+ };
79
+ const send = (method, params = {}) => new Promise((res) => {
80
+ const i = ++id;
81
+ pending.set(i, (r) => res(r));
82
+ ws.send(JSON.stringify({ id: i, method, params }));
83
+ });
84
+ return { send, close: () => ws.close() };
85
+ }
86
+ export async function runPreviewCommand(filePath, flags) {
87
+ if (!filePath)
88
+ fail("Usage: lotics preview <file.docx|.xlsx> [--out <file.png>]");
89
+ const abs = resolve(filePath);
90
+ if (!existsSync(abs))
91
+ fail(`File not found: ${abs}`);
92
+ const ext = extname(abs).toLowerCase();
93
+ const type = ext === ".docx" ? "docx" : (ext === ".xlsx" || ext === ".xls" || ext === ".csv") ? "xlsx" : null;
94
+ if (!type)
95
+ fail(`Unsupported file type "${ext}" — preview supports .docx and .xlsx/.csv. (PDFs open directly — no preview needed.)`);
96
+ // preview drives Chrome over CDP via the built-in WebSocket (Node 22+). The rest of the
97
+ // CLI supports Node 18, so fail this one command clearly rather than with "WebSocket is
98
+ // not defined" — and before launching Chrome.
99
+ if (typeof WebSocket === "undefined") {
100
+ fail("lotics preview needs Node 22+ (it drives Chrome over CDP via the built-in WebSocket). Upgrade Node and retry.");
101
+ }
102
+ const bundlePath = join(HERE, "..", "render_page.js");
103
+ if (!existsSync(bundlePath))
104
+ fail(`Render bundle missing at ${bundlePath} — reinstall @lotics/cli (build step failed).`);
105
+ const chrome = findChrome();
106
+ if (!chrome) {
107
+ fail("No Chrome/Chromium found. Set CHROME_PATH to a Chrome binary, or install one:\n" +
108
+ " npx playwright install chromium (then it's auto-detected)\n" +
109
+ " or install Google Chrome / Chromium via your package manager.");
110
+ }
111
+ const b64 = readFileSync(abs).toString("base64");
112
+ const bundle = readFileSync(bundlePath, "utf8");
113
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#fff;font-family:sans-serif}` +
114
+ `#root{padding:20px;max-width:1240px;margin:0 auto}</style></head><body><div id="root"></div>` +
115
+ `<script>window.__LOTICS_RENDER=${JSON.stringify({ type, b64 })}</script>` +
116
+ `<script src="/render_page.js"></script></body></html>`;
117
+ const server = createServer((req, res) => {
118
+ if (req.url === "/render_page.js") {
119
+ res.writeHead(200, { "content-type": "application/javascript" });
120
+ res.end(bundle);
121
+ }
122
+ else {
123
+ res.writeHead(200, { "content-type": "text/html" });
124
+ res.end(html);
125
+ }
126
+ });
127
+ await new Promise((r) => server.listen(0, "127.0.0.1", () => r()));
128
+ const httpPort = server.address().port;
129
+ const udd = mkdtempSync(join(tmpdir(), "lotics-render-"));
130
+ const child = spawn(chrome, [
131
+ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
132
+ // Large multi-sheet renders exhaust the (often tiny) /dev/shm in containers/WSL2 and
133
+ // crash the tab; back shared memory with /tmp instead.
134
+ "--disable-dev-shm-usage",
135
+ "--force-device-scale-factor=2", "--remote-debugging-port=0", `--user-data-dir=${udd}`,
136
+ "about:blank",
137
+ ], { stdio: "ignore" });
138
+ const cleanup = () => {
139
+ try {
140
+ child.kill();
141
+ }
142
+ catch { /* ignore */ }
143
+ try {
144
+ server.close();
145
+ }
146
+ catch { /* ignore */ }
147
+ try {
148
+ rmSync(udd, { recursive: true, force: true });
149
+ }
150
+ catch { /* ignore */ }
151
+ };
152
+ try {
153
+ // Chrome writes the chosen debug port to DevToolsActivePort (first line).
154
+ let cdpPort = 0;
155
+ const portFile = join(udd, "DevToolsActivePort");
156
+ for (let i = 0; i < 100 && !cdpPort; i++) {
157
+ if (existsSync(portFile)) {
158
+ const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
159
+ if (p)
160
+ cdpPort = p;
161
+ }
162
+ if (!cdpPort)
163
+ await sleep(100);
164
+ }
165
+ if (!cdpPort)
166
+ throw new Error("Chrome did not expose a debugging port (launch failed?).");
167
+ let target;
168
+ for (let i = 0; i < 60 && !target?.webSocketDebuggerUrl; i++) {
169
+ try {
170
+ const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
171
+ target = list.find((t) => t.type === "page");
172
+ }
173
+ catch { /* not ready */ }
174
+ if (!target?.webSocketDebuggerUrl)
175
+ await sleep(100);
176
+ }
177
+ if (!target?.webSocketDebuggerUrl)
178
+ throw new Error("No Chrome page target available.");
179
+ const cdp = await cdpConnect(target.webSocketDebuggerUrl);
180
+ await cdp.send("Page.enable");
181
+ await cdp.send("Runtime.enable");
182
+ await cdp.send("Page.navigate", { url: `http://127.0.0.1:${httpPort}/` });
183
+ // Poll for the render-done flag the page sets.
184
+ let err;
185
+ let done = false;
186
+ const warnings = [];
187
+ for (let i = 0; i < 200; i++) {
188
+ const r = await cdp.send("Runtime.evaluate", {
189
+ expression: "({done: !!window.__loticsDone, err: window.__loticsError || '', warnings: window.__loticsWarnings || []})",
190
+ returnByValue: true,
191
+ });
192
+ const v = r.result?.value;
193
+ if (v?.done) {
194
+ done = true;
195
+ err = v.err || undefined;
196
+ if (v.warnings?.length)
197
+ warnings.push(...v.warnings);
198
+ break;
199
+ }
200
+ await sleep(75);
201
+ }
202
+ if (!done)
203
+ throw new Error("Render timed out (page never signaled completion).");
204
+ if (err)
205
+ throw new Error(`Render engine error: ${err}`);
206
+ // Size the capture to the full rendered content, then screenshot beyond viewport.
207
+ // Clamp to Chrome's ceiling: the PNG is 2× (device scale) the CSS clip, so a CSS side
208
+ // over ~15000px would blow past the ~32767px canvas limit. A very long docx or several
209
+ // huge sheets hit this — clamp explicitly and WARN, never clip in silence.
210
+ const metrics = await cdp.send("Page.getLayoutMetrics");
211
+ const size = metrics.cssContentSize ?? metrics.contentSize ?? { width: 1240, height: 1600 };
212
+ const MAX_CAPTURE = 15000; // CSS px; ×2 device scale → ≤30000px PNG
213
+ const w = Math.ceil(size.width);
214
+ const h = Math.ceil(size.height);
215
+ if (w > MAX_CAPTURE || h > MAX_CAPTURE) {
216
+ warnings.push(`content ${w}×${h}px clipped to ${Math.min(w, MAX_CAPTURE)}×${Math.min(h, MAX_CAPTURE)}px (exceeds the ${MAX_CAPTURE}px capture limit)`);
217
+ }
218
+ const shot = await cdp.send("Page.captureScreenshot", {
219
+ format: "png",
220
+ captureBeyondViewport: true,
221
+ clip: { x: 0, y: 0, width: Math.min(w, MAX_CAPTURE), height: Math.min(h, MAX_CAPTURE), scale: 1 },
222
+ });
223
+ cdp.close();
224
+ const outPath = flags.output ? resolve(flags.output) : join(dirname(abs), basename(abs, ext) + ".png");
225
+ writeFileSync(outPath, Buffer.from(shot.data, "base64"));
226
+ console.log(`Rendered ${basename(abs)} → ${outPath} (${Math.min(w, MAX_CAPTURE)}×${Math.min(h, MAX_CAPTURE)}, via ${basename(chrome)})`);
227
+ for (const warn of warnings)
228
+ console.error(` ⚠ ${warn}`);
229
+ }
230
+ finally {
231
+ cleanup();
232
+ }
233
+ }