@openbrt/weclawbotctl 0.1.18 → 0.1.19

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.
@@ -10,6 +10,7 @@ import process from "node:process";
10
10
  import { validateActivity } from "../lib/activity.mjs";
11
11
  import { validateScreenDocument } from "../lib/direct-control.mjs";
12
12
  import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "../lib/mqtt-control.mjs";
13
+ import { createPreviewArtifactDir, writePreviewManifest } from "../lib/preview-manifest.mjs";
13
14
  import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
14
15
 
15
16
  const DEFAULT_ENDPOINT = "https://weclawbot.link/byoa";
@@ -212,6 +213,7 @@ async function commandScreen(values) {
212
213
  timeout: 12,
213
214
  preview: true,
214
215
  "preview-dir": "",
216
+ "preview-manifest": true,
215
217
  scale: 2,
216
218
  });
217
219
  const file = String(options._[0] || "").trim();
@@ -229,9 +231,10 @@ async function commandScreen(values) {
229
231
  if (!validation.ok) {
230
232
  throw new Error(`Invalid screen document: ${validation.errors.join("; ")}`);
231
233
  }
234
+ const previewOutputDir = options["preview-dir"] || (options["preview-manifest"] ? await createPreviewArtifactDir(document.id || "screen") : "");
232
235
  const preview = options.preview
233
236
  ? await writeScreenDocumentPreviewFiles(document, {
234
- outputDir: options["preview-dir"] || "",
237
+ outputDir: previewOutputDir,
235
238
  scale: Number(options.scale) || 2,
236
239
  })
237
240
  : { available: false, pages: [] };
@@ -250,6 +253,14 @@ async function commandScreen(values) {
250
253
  if (delivery.status.kind !== "applied") {
251
254
  throw new Error(`Device rejected screen document: ${delivery.status.detail || "unknown"}`);
252
255
  }
256
+ const manifest = options["preview-manifest"]
257
+ ? await writePreviewManifest({
258
+ document,
259
+ preview,
260
+ source: "weclawbotctl_screen",
261
+ status: { applied: true, mqtt_status: delivery.status },
262
+ })
263
+ : null;
253
264
  console.log(JSON.stringify({
254
265
  ok: true,
255
266
  published: true,
@@ -260,11 +271,20 @@ async function commandScreen(values) {
260
271
  warnings: validation.warnings,
261
272
  layout_guidance: validation.layout_guidance,
262
273
  preview,
274
+ preview_manifest: previewManifestSummary(manifest),
263
275
  status: delivery.status,
264
276
  }));
265
277
  return;
266
278
  }
267
279
  await publishControl(credentials, control);
280
+ const manifest = options["preview-manifest"]
281
+ ? await writePreviewManifest({
282
+ document,
283
+ preview,
284
+ source: "weclawbotctl_screen",
285
+ status: { applied: null },
286
+ })
287
+ : null;
268
288
  console.log(JSON.stringify({
269
289
  ok: true,
270
290
  published: true,
@@ -275,6 +295,7 @@ async function commandScreen(values) {
275
295
  warnings: validation.warnings,
276
296
  layout_guidance: validation.layout_guidance,
277
297
  preview,
298
+ preview_manifest: previewManifestSummary(manifest),
278
299
  }));
279
300
  }
280
301
 
@@ -767,6 +788,15 @@ function clearStatusDetail(target) {
767
788
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
768
789
  }
769
790
 
791
+ function previewManifestSummary(manifest) {
792
+ if (!manifest) return null;
793
+ return {
794
+ id: manifest.id,
795
+ path: manifest.path,
796
+ pages: manifest.preview?.pages?.length || 0,
797
+ };
798
+ }
799
+
770
800
  function shellValue(value) {
771
801
  return `'${String(value).replaceAll("'", "'\\''")}'`;
772
802
  }
@@ -785,7 +815,7 @@ function usage() {
785
815
  weclawbotctl thinking [--ttl seconds] [--id correlation-id]
786
816
  weclawbotctl idle [--id correlation-id]
787
817
  weclawbotctl preview <document.json> [--output-dir dir] [--scale 1..4]
788
- weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir] [--no-preview]
818
+ weclawbotctl screen <document.json> [--force] [--no-wait] [--timeout seconds] [--preview-dir dir] [--no-preview] [--no-preview-manifest]
789
819
  weclawbotctl clear [--target note|idle_photo] [--no-wait] [--timeout seconds]
790
820
  weclawbotctl openclaw install [--spec @openbrt/weclawbotctl] [--bin /path/to/openclaw] [--force=false]
791
821
  weclawbotctl openclaw doctor [--gateway=false] [--bin /path/to/openclaw] [--json]`);
package/index.mjs CHANGED
@@ -10,11 +10,13 @@ import { validateActivity } from "./lib/activity.mjs";
10
10
  import { validateScreenDocument } from "./lib/direct-control.mjs";
11
11
  import { normalizeCredentials, publishControl, publishControlAndWaitStatus, testConnection } from "./lib/mqtt-control.mjs";
12
12
  import { collectCommandStrings, extractScreenDocumentPathFromExecParams, normalizeDeliveryContext } from "./lib/openclaw-preview.mjs";
13
+ import { createPreviewArtifactDir, listRecentPreviewManifests, markPreviewManifest, writePreviewManifest } from "./lib/preview-manifest.mjs";
13
14
  import { renderScreenDocumentPreviewPages, writeScreenDocumentPreviewFiles } from "./lib/screen-preview.mjs";
14
15
 
15
16
  const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".config", "weclawbot", "agent-mqtt.json");
16
17
  const SCREEN_PROMPT_PATTERN = /weclawbot|weclaw|微笺|桌宠|墨水屏|电子墨水|屏上|上屏|发到屏|推到屏|放到屏|显示到屏|发送到屏|清屏|清理屏幕|屏幕/iu;
17
18
  const activeRunActivities = new Map();
19
+ const activePreviewWindows = new Map();
18
20
 
19
21
  // The long-running curator bridge remains a separate service. These tools keep
20
22
  // the local agent path explicit: validate first, then publish only with the
@@ -136,12 +138,21 @@ const pluginEntry = defineToolPlugin({
136
138
  document: outbound,
137
139
  };
138
140
  const credentials = await requireCredentials(credentials_path);
139
- const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: preview_output_dir || "" });
141
+ const previewDir = preview_output_dir || await createPreviewArtifactDir(outbound.id || "screen");
142
+ const preview = await writeScreenDocumentPreviewFiles(outbound, { outputDir: previewDir });
140
143
  if (wait_status !== false) {
141
144
  const delivery = await publishControlAndWaitStatus(credentials, control, {
142
145
  expectedDetail: outbound.id,
143
146
  timeoutMs: Math.max(1, Number(timeout_seconds) || 12) * 1000,
144
147
  });
148
+ const manifest = delivery.status.kind === "applied"
149
+ ? await writePreviewManifest({
150
+ document: outbound,
151
+ preview,
152
+ source: "openclaw_tool",
153
+ status: { applied: true, mqtt_status: delivery.status },
154
+ })
155
+ : null;
145
156
  return {
146
157
  ok: delivery.status.kind === "applied",
147
158
  published: true,
@@ -153,10 +164,17 @@ const pluginEntry = defineToolPlugin({
153
164
  warnings: validation.warnings,
154
165
  layout_guidance: validation.layout_guidance,
155
166
  preview,
167
+ preview_manifest: previewManifestSummary(manifest),
156
168
  status: delivery.status,
157
169
  };
158
170
  }
159
171
  await publishControl(credentials, control);
172
+ const manifest = await writePreviewManifest({
173
+ document: outbound,
174
+ preview,
175
+ source: "openclaw_tool",
176
+ status: { applied: null },
177
+ });
160
178
  return {
161
179
  ok: true,
162
180
  published: true,
@@ -167,6 +185,7 @@ const pluginEntry = defineToolPlugin({
167
185
  warnings: validation.warnings,
168
186
  layout_guidance: validation.layout_guidance,
169
187
  preview,
188
+ preview_manifest: previewManifestSummary(manifest),
170
189
  };
171
190
  },
172
191
  }),
@@ -228,11 +247,11 @@ function registerOpenClawHooks(api) {
228
247
  return { outcome: "pass" };
229
248
  }, { timeoutMs: 5_000 });
230
249
  api.on("before_agent_finalize", async (event, ctx) => {
231
- await finishHookActivity(api, event, ctx);
250
+ await finishHookActivity(api, event, ctx, { final: false });
232
251
  return { action: "continue" };
233
252
  }, { timeoutMs: 5_000 });
234
253
  api.on("agent_end", async (event, ctx) => {
235
- await finishHookActivity(api, event, ctx);
254
+ await finishHookActivity(api, event, ctx, { final: true });
236
255
  }, { timeoutMs: 5_000 });
237
256
  api.on("after_tool_call", async (event, ctx) => {
238
257
  await attachScreenPreview(api, event, ctx);
@@ -241,10 +260,14 @@ function registerOpenClawHooks(api) {
241
260
 
242
261
  async function startHookActivity(api, event, ctx) {
243
262
  try {
244
- if (api.pluginConfig?.auto_activity === false) return;
245
263
  if (!shouldAutoActivity(event, ctx)) return;
246
264
  const key = hookActivityKey(event, ctx);
247
- if (!key || activeRunActivities.has(key)) return;
265
+ if (!key) return;
266
+ if (!activePreviewWindows.has(key)) {
267
+ activePreviewWindows.set(key, { startedAt: Date.now() });
268
+ }
269
+ if (api.pluginConfig?.auto_activity === false) return;
270
+ if (activeRunActivities.has(key)) return;
248
271
  const correlationId = `openclaw-${sanitizeId(ctx?.runId || key).slice(0, 72)}`;
249
272
  await publishControl(await requireCredentials(), {
250
273
  schema: "weclawbot.control.v1",
@@ -264,10 +287,20 @@ async function startHookActivity(api, event, ctx) {
264
287
  }
265
288
  }
266
289
 
267
- async function finishHookActivity(api, event, ctx) {
290
+ async function finishHookActivity(api, event, ctx, options = {}) {
291
+ const key = hookActivityKey(event, ctx);
292
+ if (!key) return;
293
+ const previewWindow = activePreviewWindows.get(key);
294
+ if (previewWindow) {
295
+ try {
296
+ await attachRecentPreviewManifests(api, event, ctx, previewWindow);
297
+ } catch (error) {
298
+ api.logger?.warn?.(`weclawbot preview manifest hook failed: ${errorMessage(error)}`);
299
+ } finally {
300
+ if (options.final) activePreviewWindows.delete(key);
301
+ }
302
+ }
268
303
  try {
269
- const key = hookActivityKey(event, ctx);
270
- if (!key) return;
271
304
  const active = activeRunActivities.get(key);
272
305
  if (!active) return;
273
306
  activeRunActivities.delete(key);
@@ -287,6 +320,53 @@ async function finishHookActivity(api, event, ctx) {
287
320
  }
288
321
  }
289
322
 
323
+ async function attachRecentPreviewManifests(api, event, ctx, previewWindow) {
324
+ if (api.pluginConfig?.auto_preview === false) return 0;
325
+ if (event?.error) return 0;
326
+ const sessionKey = resolveHookSessionKey(event, ctx);
327
+ if (!sessionKey) {
328
+ logPreviewSkip(api, event, "missing sessionKey for manifest delivery");
329
+ return 0;
330
+ }
331
+ const manifests = await listRecentPreviewManifests({
332
+ sinceMs: Math.max(0, Number(previewWindow?.startedAt) || Date.now() - 60_000),
333
+ untilMs: Date.now() + 10_000,
334
+ limit: 5,
335
+ });
336
+ let delivered = 0;
337
+ for (const manifest of manifests) {
338
+ const result = await attachPreviewManifest(api, manifest, { ...ctx, sessionKey });
339
+ if (result?.ok) {
340
+ delivered += result.count || 1;
341
+ await markPreviewManifest(manifest.path, {
342
+ delivered_at: new Date().toISOString(),
343
+ delivered_session_key: sessionKey,
344
+ delivered_channel: result.channel,
345
+ });
346
+ } else {
347
+ await markPreviewManifest(manifest.path, {
348
+ last_delivery_error_at: new Date().toISOString(),
349
+ last_delivery_error: result?.error || "unknown error",
350
+ });
351
+ api.logger?.warn?.(`weclawbot preview manifest delivery failed: ${result?.error || "unknown error"}`);
352
+ }
353
+ }
354
+ return delivered;
355
+ }
356
+
357
+ async function attachPreviewManifest(api, manifest, ctx) {
358
+ const pages = Array.isArray(manifest.preview?.pages) ? manifest.preview.pages : [];
359
+ const files = pages
360
+ .map((page) => ({ path: String(page.path || "") }))
361
+ .filter((file) => file.path);
362
+ if (files.length === 0) return { ok: false, error: "manifest has no preview files" };
363
+ const documentId = manifest.document?.id || "screen";
364
+ const pageLabel = `${files.length} page${files.length === 1 ? "" : "s"}`;
365
+ const caption = `WeClawBot screen preview: ${documentId} (${pageLabel}, ${manifest.source || "manifest"})`;
366
+ const dir = manifest.preview?.output_dir || path.dirname(files[0].path);
367
+ return deliverPreviewFiles(api, { ctx, files, caption, dir });
368
+ }
369
+
290
370
  async function attachScreenPreview(api, event, ctx) {
291
371
  try {
292
372
  if (api.pluginConfig?.auto_preview !== true) return;
@@ -343,12 +423,22 @@ async function attachPreviewForDocument(api, document, ctx, source) {
343
423
  }
344
424
  const pageLabel = `${previewPages.length} page${previewPages.length === 1 ? "" : "s"}`;
345
425
  const caption = `WeClawBot screen preview: ${document.id || "screen"} (${pageLabel}, ${source})`;
426
+ const result = await deliverPreviewFiles(api, { ctx, files, caption, dir });
427
+ if (!result?.ok) {
428
+ api.logger?.warn?.(`weclawbot preview delivery failed: ${result?.error || "unknown error"}`);
429
+ } else {
430
+ api.logger?.info?.(`weclawbot preview attached: ${result.channel} count=${result.count}`);
431
+ }
432
+ scheduleRemove(dir);
433
+ }
434
+
435
+ async function deliverPreviewFiles(api, params) {
346
436
  let attached = false;
347
437
  if (typeof api.session?.workflow?.sendSessionAttachment === "function") {
348
438
  const result = await api.session.workflow.sendSessionAttachment({
349
- sessionKey: ctx.sessionKey,
350
- files,
351
- text: caption,
439
+ sessionKey: params.ctx.sessionKey,
440
+ files: params.files,
441
+ text: params.caption,
352
442
  maxBytes: 2_000_000,
353
443
  captionFormat: "plain",
354
444
  channelHints: { telegram: { forceDocumentMime: "image/png" } },
@@ -362,20 +452,19 @@ async function attachPreviewForDocument(api, document, ctx, source) {
362
452
  }
363
453
  if (!attached) {
364
454
  const result = await sendPreviewViaOutboundAdapter(api, {
365
- ctx,
366
- files,
367
- document,
368
- source,
369
- caption,
370
- dir,
455
+ ctx: params.ctx,
456
+ files: params.files,
457
+ caption: params.caption,
458
+ dir: params.dir,
371
459
  });
372
460
  if (!result?.ok) {
373
- api.logger?.warn?.(`weclawbot preview outbound fallback failed: ${result?.error || "unknown error"}`);
461
+ return result;
374
462
  } else {
375
463
  api.logger?.info?.(`weclawbot preview attached via outbound adapter: ${result.channel} count=${result.count}`);
464
+ return result;
376
465
  }
377
466
  }
378
- scheduleRemove(dir);
467
+ return { ok: true, channel: "session-workflow", count: params.files.length };
379
468
  }
380
469
 
381
470
  async function sendPreviewViaOutboundAdapter(api, params) {
@@ -502,6 +591,15 @@ function clearStatusDetail(target) {
502
591
  return target === "idle_photo" ? "clear_idle_photo" : "clear_note";
503
592
  }
504
593
 
594
+ function previewManifestSummary(manifest) {
595
+ if (!manifest) return null;
596
+ return {
597
+ id: manifest.id,
598
+ path: manifest.path,
599
+ pages: manifest.preview?.pages?.length || 0,
600
+ };
601
+ }
602
+
505
603
  function sanitizeId(value) {
506
604
  return String(value || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/gu, "_");
507
605
  }
@@ -0,0 +1,133 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const MANIFEST_SCHEMA = "weclawbot.preview_manifest.v1";
7
+
8
+ export async function defaultPreviewManifestRoot() {
9
+ const override = expandPath(process.env.WECLAWBOT_PREVIEW_SPOOL_DIR || "");
10
+ if (override) return override;
11
+
12
+ const stateDir = expandPath(process.env.OPENCLAW_STATE_DIR || "");
13
+ if (stateDir) return path.join(stateDir, "media", "outbound", "weclawbot-preview");
14
+
15
+ const openClawOutbound = path.join(os.homedir(), ".openclaw", "media", "outbound");
16
+ if (await isDirectory(openClawOutbound)) return path.join(openClawOutbound, "weclawbot-preview");
17
+
18
+ return path.join(os.homedir(), ".cache", "weclawbot", "openclaw-preview");
19
+ }
20
+
21
+ export async function createPreviewArtifactDir(documentId = "screen") {
22
+ const root = await defaultPreviewManifestRoot();
23
+ const now = new Date();
24
+ const stamp = now.toISOString().replace(/[:.]/gu, "-");
25
+ const dir = path.join(root, "files", `${stamp}-${process.pid}-${safeFilename(documentId)}`);
26
+ await fs.mkdir(dir, { recursive: true });
27
+ return dir;
28
+ }
29
+
30
+ export async function writePreviewManifest({ document, preview, source = "unknown", status = {}, createdAtMs = Date.now() }) {
31
+ if (!preview?.available || !Array.isArray(preview.pages) || preview.pages.length === 0) return null;
32
+ const root = await defaultPreviewManifestRoot();
33
+ const dir = path.join(root, "manifests");
34
+ await fs.mkdir(dir, { recursive: true });
35
+
36
+ const id = `preview_${crypto.randomUUID()}`;
37
+ const createdAt = new Date(createdAtMs).toISOString();
38
+ const manifest = {
39
+ schema: MANIFEST_SCHEMA,
40
+ id,
41
+ created_at: createdAt,
42
+ created_ms: createdAtMs,
43
+ source,
44
+ document: {
45
+ id: String(document?.id || "screen"),
46
+ pages: Array.isArray(document?.pages) ? document.pages.length : preview.pages.length,
47
+ },
48
+ status,
49
+ preview: {
50
+ available: true,
51
+ output_dir: preview.output_dir || path.dirname(preview.pages[0]?.path || ""),
52
+ pages: preview.pages.map((page) => ({
53
+ index: Number(page.index) || 0,
54
+ path: String(page.path || ""),
55
+ width: Number(page.width) || 0,
56
+ height: Number(page.height) || 0,
57
+ scale: Number(page.scale) || 1,
58
+ bytes: Number(page.bytes) || 0,
59
+ sha256: String(page.sha256 || ""),
60
+ mime_type: page.mime_type || "image/png",
61
+ })),
62
+ },
63
+ };
64
+
65
+ const file = path.join(dir, `${createdAtMs}-${safeFilename(document?.id || source)}-${id}.json`);
66
+ await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
67
+ return { ...manifest, path: file, root };
68
+ }
69
+
70
+ export async function listRecentPreviewManifests(options = {}) {
71
+ const root = await defaultPreviewManifestRoot();
72
+ const dir = path.join(root, "manifests");
73
+ const sinceMs = Number(options.sinceMs) || 0;
74
+ const untilMs = Number(options.untilMs) || Date.now() + 10_000;
75
+ const limit = Math.max(1, Number(options.limit) || 8);
76
+ let entries = [];
77
+ try {
78
+ entries = await fs.readdir(dir, { withFileTypes: true });
79
+ } catch (error) {
80
+ if (error?.code === "ENOENT") return [];
81
+ throw error;
82
+ }
83
+
84
+ const manifests = [];
85
+ for (const entry of entries) {
86
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
87
+ const file = path.join(dir, entry.name);
88
+ let stat;
89
+ let manifest;
90
+ try {
91
+ stat = await fs.stat(file);
92
+ if (stat.mtimeMs < sinceMs - 10_000 || stat.mtimeMs > untilMs + 10_000) continue;
93
+ manifest = JSON.parse(await fs.readFile(file, "utf8"));
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (manifest?.schema !== MANIFEST_SCHEMA) continue;
98
+ if (manifest.delivered_at || manifest.consumed_at) continue;
99
+ const createdMs = Number(manifest.created_ms) || Date.parse(manifest.created_at || "") || stat.mtimeMs;
100
+ if (createdMs < sinceMs || createdMs > untilMs) continue;
101
+ const pages = Array.isArray(manifest.preview?.pages) ? manifest.preview.pages : [];
102
+ if (pages.length === 0) continue;
103
+ manifests.push({ ...manifest, created_ms: createdMs, path: file, root });
104
+ }
105
+
106
+ return manifests
107
+ .sort((a, b) => a.created_ms - b.created_ms)
108
+ .slice(0, limit);
109
+ }
110
+
111
+ export async function markPreviewManifest(file, patch) {
112
+ const payload = JSON.parse(await fs.readFile(file, "utf8"));
113
+ const updated = { ...payload, ...patch };
114
+ await fs.writeFile(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
115
+ return updated;
116
+ }
117
+
118
+ async function isDirectory(file) {
119
+ try {
120
+ return (await fs.stat(file)).isDirectory();
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function expandPath(value) {
127
+ const raw = String(value || "").trim();
128
+ return raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
129
+ }
130
+
131
+ function safeFilename(value) {
132
+ return String(value || "screen").replace(/[^a-zA-Z0-9_.-]/gu, "_").slice(0, 80) || "screen";
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/weclawbotctl",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "WeClawBot pairing and screen-control CLI for local AI agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,11 +43,12 @@
43
43
  "./activity": "./lib/activity.mjs",
44
44
  "./direct-control": "./lib/direct-control.mjs",
45
45
  "./mqtt-control": "./lib/mqtt-control.mjs",
46
+ "./preview-manifest": "./lib/preview-manifest.mjs",
46
47
  "./screen-preview": "./lib/screen-preview.mjs",
47
48
  "./package.json": "./package.json"
48
49
  },
49
50
  "scripts": {
50
- "check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs test/openclaw-preview-hook.test.mjs"
51
+ "check": "node --check index.mjs && node --check lib/openclaw-preview.mjs && node --check lib/preview-manifest.mjs && node --check bin/weclawbot-openclaw-bridge.mjs && node --check bin/weclawbot-byoa-bind.mjs && node --check bin/weclawbotctl.mjs && node --test test/direct-control.test.mjs test/activity.test.mjs test/screen-preview.test.mjs test/preview-manifest.test.mjs test/openclaw-preview-hook.test.mjs"
51
52
  },
52
53
  "dependencies": {
53
54
  "mqtt": "^5.10.4",
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ createPreviewArtifactDir,
8
+ listRecentPreviewManifests,
9
+ markPreviewManifest,
10
+ writePreviewManifest,
11
+ } from "../lib/preview-manifest.mjs";
12
+ import { writeScreenDocumentPreviewFiles } from "../lib/screen-preview.mjs";
13
+
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "weclawbot-preview-manifest-test-"));
15
+ process.env.WECLAWBOT_PREVIEW_SPOOL_DIR = root;
16
+
17
+ const width = 8;
18
+ const height = 8;
19
+ const stride = 1;
20
+ const bytes = Buffer.alloc(stride * height, 0x00);
21
+ for (let y = 0; y < height; y += 1) bytes[y * stride] = 0x80;
22
+
23
+ const document = {
24
+ schema: "weclawbot.screen_document.v1",
25
+ id: "manifest-test",
26
+ pages: [{
27
+ format: "mono1",
28
+ width,
29
+ height,
30
+ stride,
31
+ data_b64: bytes.toString("base64"),
32
+ }],
33
+ };
34
+
35
+ const artifactDir = await createPreviewArtifactDir(document.id);
36
+ assert.ok(artifactDir.startsWith(root));
37
+
38
+ const preview = await writeScreenDocumentPreviewFiles(document, { outputDir: artifactDir });
39
+ const manifest = await writePreviewManifest({
40
+ document,
41
+ preview,
42
+ source: "test",
43
+ status: { applied: true },
44
+ });
45
+
46
+ assert.ok(manifest.path.startsWith(root));
47
+ assert.equal(manifest.preview.pages.length, 1);
48
+
49
+ const recent = await listRecentPreviewManifests({ sinceMs: Date.now() - 10_000 });
50
+ assert.equal(recent.length, 1);
51
+ assert.equal(recent[0].id, manifest.id);
52
+
53
+ await markPreviewManifest(manifest.path, { delivered_at: new Date().toISOString() });
54
+ const afterDelivery = await listRecentPreviewManifests({ sinceMs: Date.now() - 10_000 });
55
+ assert.equal(afterDelivery.length, 0);
56
+
57
+ delete process.env.WECLAWBOT_PREVIEW_SPOOL_DIR;
58
+ await fs.rm(root, { recursive: true, force: true });
59
+
60
+ console.log("preview-manifest spool: ok");