@stardeck-customer-apps/testing 0.6.2 → 0.7.1

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.
package/SKILL.md CHANGED
@@ -175,7 +175,8 @@ externalId })` helper models a trusted platform/channel link, and
175
175
  `.all()`, `.latest()`, `.to(recipient)`, `.channel("line")`, `.count`, `.clear()`.
176
176
  - `app.edge` — edge print/display/bindings from edge-sdk: `.prints`, `.displays`,
177
177
  `.testPrints`, `.latestPrint()`, `.latestDisplay()`, `.bindings`,
178
- `.seedDevices()`, `.seedPeripherals()`, `.seedBindings()`, `.count`, `.clear()`.
178
+ `.seedDevices()`, `.seedPeripherals()`, `.seedBindings()`,
179
+ `.seedCapabilities(caps, peripheralId?)`, `.count`, `.clear()`.
179
180
  - `app.query(sql, params?)` / `app.db` — direct database access for
180
181
  assertions and seeding.
181
182
  - `callRoute(handler, opts)` — invoke an App Router route handler with a real
@@ -242,8 +243,32 @@ cursor, limit })`; `list()` intentionally keeps its previous array shape.
242
243
  - `client.slack` / `client.line` / `client.facebook` send endpoints — captured
243
244
  in `app.messages`.
244
245
  - `createEdgeClient()` (print, pair/unpair, list devices/peripherals, show/clear
245
- display, test print) — captured in `app.edge`; seed devices/peripherals for
246
- pairing pickers via `app.edge.seedDevices()` / `seedPeripherals()`.
246
+ display, test print, `getCapabilities()`) — captured in `app.edge`; seed
247
+ devices/peripherals for pairing pickers via `app.edge.seedDevices()` /
248
+ `seedPeripherals()`.
249
+
250
+ ### Receipt layout and printer capabilities
251
+
252
+ `edge.getCapabilities(target)` answers with a stock 80mm text-mode printer
253
+ unless the test says otherwise. Seed a different one to exercise layout,
254
+ degradation, and preview code — per peripheral when a test models more than one
255
+ station:
256
+
257
+ ```ts
258
+ app.edge.seedPeripherals([kitchenPrinter, counterPrinter]);
259
+ app.edge.seedCapabilities({ ...caps58mm, renderMode: "raster" }, kitchenPrinter.id);
260
+
261
+ const caps = await edge.getCapabilities({ peripheralId: kitchenPrinter.id });
262
+ expect(caps.columns).toBe(32);
263
+ ```
264
+
265
+ The simulator resolves the target the way production does: an unpaired alias
266
+ throws `BindingError` (`BINDING_NOT_FOUND`) and an unknown peripheral 404s, so
267
+ target-resolution bugs surface in tests rather than in the field.
268
+
269
+ Printed receipts are captured whole, so assert on the ordered blocks an app
270
+ produced — `app.edge.latestPrint()?.receipt.blocks` — not just its totals.
271
+
247
272
  - `next/headers` (`headers()`/`cookies()`) inside handlers under `callRoute`.
248
273
 
249
274
  ## End-to-end (Playwright) suite
package/dist/index.d.mts CHANGED
@@ -184,6 +184,10 @@ interface CapturedMessage {
184
184
  threadTs?: string;
185
185
  messagingType?: string;
186
186
  tag?: string;
187
+ /** LINE image push — HTTPS URL of the full-size image. */
188
+ originalContentUrl?: string;
189
+ /** LINE image push — HTTPS URL of the preview; may be omitted when not sent. */
190
+ previewImageUrl?: string;
187
191
  };
188
192
  connectionId?: string;
189
193
  sentAt: Date;
@@ -200,17 +204,51 @@ interface TestMessages {
200
204
  clear(): void;
201
205
  get count(): number;
202
206
  }
207
+ interface ReceiptItem {
208
+ name: string;
209
+ quantity: number;
210
+ unitPrice: number;
211
+ total: number;
212
+ note?: string;
213
+ }
214
+ type ReceiptAlign = "left" | "center" | "right";
215
+ type ReceiptTextSize = "small" | "normal" | "large";
216
+ /** Mirrors the edge SDK's block model so assertions can inspect the exact
217
+ * ordered content an app printed, not just its totals. */
218
+ type ReceiptBlock = {
219
+ type: "text";
220
+ text: string;
221
+ align?: ReceiptAlign;
222
+ bold?: boolean;
223
+ size?: ReceiptTextSize;
224
+ } | {
225
+ type: "columns";
226
+ left: string;
227
+ right: string;
228
+ bold?: boolean;
229
+ size?: ReceiptTextSize;
230
+ } | {
231
+ type: "separator";
232
+ style?: "dashed" | "solid";
233
+ } | {
234
+ type: "spacer";
235
+ lines?: number;
236
+ } | {
237
+ type: "items";
238
+ rows: ReceiptItem[];
239
+ } | {
240
+ type: "barcode";
241
+ format: "qr" | "code128";
242
+ data: string;
243
+ align?: ReceiptAlign;
244
+ };
203
245
  interface ReceiptPayload {
246
+ paperWidth?: "58mm" | "80mm";
247
+ blocks?: ReceiptBlock[];
204
248
  header?: {
205
249
  lines: string[];
206
250
  };
207
- items?: Array<{
208
- name: string;
209
- quantity: number;
210
- unitPrice: number;
211
- total: number;
212
- note?: string;
213
- }>;
251
+ items?: ReceiptItem[];
214
252
  subtotal?: number;
215
253
  tax?: number;
216
254
  discount?: number;
@@ -228,6 +266,23 @@ interface ReceiptPayload {
228
266
  };
229
267
  rawEscPos?: string;
230
268
  }
269
+ /** Mirrors the edge SDK's PrinterCapabilities so tests can drive
270
+ * `edge.getCapabilities()` without the SDK being a dependency here. */
271
+ interface PrinterCapabilities {
272
+ paperWidth: "58mm" | "80mm";
273
+ columns: number;
274
+ dotsPerLine: number;
275
+ renderMode: "text" | "raster";
276
+ textEncoding: "utf8" | "tis620";
277
+ unicodeText: boolean;
278
+ thaiText: boolean;
279
+ bold: boolean;
280
+ textSizes: ReceiptTextSize[];
281
+ barcodeFormats: Array<"qr" | "code128">;
282
+ rasterImages: boolean;
283
+ cut: boolean;
284
+ cashDrawer: boolean;
285
+ }
231
286
  interface DeviceInfo {
232
287
  id: string;
233
288
  displayName: string;
@@ -297,6 +352,13 @@ interface TestEdge {
297
352
  seedDevices(devices: DeviceInfo[]): void;
298
353
  seedPeripherals(peripherals: PeripheralInfo[]): void;
299
354
  seedBindings(bindings: BindingInfo[]): void;
355
+ /**
356
+ * Answer `edge.getCapabilities()` with a specific printer — e.g. a 58mm or a
357
+ * raster/Thai one — so layout and preview code can be tested against it.
358
+ * Pass a `peripheralId` to model stations with different printers; without
359
+ * one this sets the default every untargeted lookup falls back to.
360
+ */
361
+ seedCapabilities(capabilities: PrinterCapabilities | null, peripheralId?: string): void;
300
362
  clear(): void;
301
363
  get count(): number;
302
364
  }
package/dist/index.d.ts CHANGED
@@ -184,6 +184,10 @@ interface CapturedMessage {
184
184
  threadTs?: string;
185
185
  messagingType?: string;
186
186
  tag?: string;
187
+ /** LINE image push — HTTPS URL of the full-size image. */
188
+ originalContentUrl?: string;
189
+ /** LINE image push — HTTPS URL of the preview; may be omitted when not sent. */
190
+ previewImageUrl?: string;
187
191
  };
188
192
  connectionId?: string;
189
193
  sentAt: Date;
@@ -200,17 +204,51 @@ interface TestMessages {
200
204
  clear(): void;
201
205
  get count(): number;
202
206
  }
207
+ interface ReceiptItem {
208
+ name: string;
209
+ quantity: number;
210
+ unitPrice: number;
211
+ total: number;
212
+ note?: string;
213
+ }
214
+ type ReceiptAlign = "left" | "center" | "right";
215
+ type ReceiptTextSize = "small" | "normal" | "large";
216
+ /** Mirrors the edge SDK's block model so assertions can inspect the exact
217
+ * ordered content an app printed, not just its totals. */
218
+ type ReceiptBlock = {
219
+ type: "text";
220
+ text: string;
221
+ align?: ReceiptAlign;
222
+ bold?: boolean;
223
+ size?: ReceiptTextSize;
224
+ } | {
225
+ type: "columns";
226
+ left: string;
227
+ right: string;
228
+ bold?: boolean;
229
+ size?: ReceiptTextSize;
230
+ } | {
231
+ type: "separator";
232
+ style?: "dashed" | "solid";
233
+ } | {
234
+ type: "spacer";
235
+ lines?: number;
236
+ } | {
237
+ type: "items";
238
+ rows: ReceiptItem[];
239
+ } | {
240
+ type: "barcode";
241
+ format: "qr" | "code128";
242
+ data: string;
243
+ align?: ReceiptAlign;
244
+ };
203
245
  interface ReceiptPayload {
246
+ paperWidth?: "58mm" | "80mm";
247
+ blocks?: ReceiptBlock[];
204
248
  header?: {
205
249
  lines: string[];
206
250
  };
207
- items?: Array<{
208
- name: string;
209
- quantity: number;
210
- unitPrice: number;
211
- total: number;
212
- note?: string;
213
- }>;
251
+ items?: ReceiptItem[];
214
252
  subtotal?: number;
215
253
  tax?: number;
216
254
  discount?: number;
@@ -228,6 +266,23 @@ interface ReceiptPayload {
228
266
  };
229
267
  rawEscPos?: string;
230
268
  }
269
+ /** Mirrors the edge SDK's PrinterCapabilities so tests can drive
270
+ * `edge.getCapabilities()` without the SDK being a dependency here. */
271
+ interface PrinterCapabilities {
272
+ paperWidth: "58mm" | "80mm";
273
+ columns: number;
274
+ dotsPerLine: number;
275
+ renderMode: "text" | "raster";
276
+ textEncoding: "utf8" | "tis620";
277
+ unicodeText: boolean;
278
+ thaiText: boolean;
279
+ bold: boolean;
280
+ textSizes: ReceiptTextSize[];
281
+ barcodeFormats: Array<"qr" | "code128">;
282
+ rasterImages: boolean;
283
+ cut: boolean;
284
+ cashDrawer: boolean;
285
+ }
231
286
  interface DeviceInfo {
232
287
  id: string;
233
288
  displayName: string;
@@ -297,6 +352,13 @@ interface TestEdge {
297
352
  seedDevices(devices: DeviceInfo[]): void;
298
353
  seedPeripherals(peripherals: PeripheralInfo[]): void;
299
354
  seedBindings(bindings: BindingInfo[]): void;
355
+ /**
356
+ * Answer `edge.getCapabilities()` with a specific printer — e.g. a 58mm or a
357
+ * raster/Thai one — so layout and preview code can be tested against it.
358
+ * Pass a `peripheralId` to model stations with different printers; without
359
+ * one this sets the default every untargeted lookup falls back to.
360
+ */
361
+ seedCapabilities(capabilities: PrinterCapabilities | null, peripheralId?: string): void;
300
362
  clear(): void;
301
363
  get count(): number;
302
364
  }
package/dist/index.js CHANGED
@@ -117,6 +117,8 @@ var state = globalSingleton("state", () => ({
117
117
  edgeDevices: [],
118
118
  edgePeripherals: [],
119
119
  edgeBindings: /* @__PURE__ */ new Map(),
120
+ edgeCapabilities: null,
121
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
120
122
  allowNetwork: false
121
123
  }));
122
124
  function requireDb() {
@@ -1695,6 +1697,15 @@ function createStorage() {
1695
1697
 
1696
1698
  // src/simulator/messaging.ts
1697
1699
  var import_node_crypto7 = __toESM(require("crypto"));
1700
+ function isValidLineImageUrl(url) {
1701
+ if (url.length > 2e3 || !url.startsWith("https://")) return false;
1702
+ try {
1703
+ new URL(url);
1704
+ return true;
1705
+ } catch {
1706
+ return false;
1707
+ }
1708
+ }
1698
1709
  function captureMessage(channel, recipient, body, connectionId) {
1699
1710
  const message = {
1700
1711
  channel,
@@ -1704,7 +1715,9 @@ function captureMessage(channel, recipient, body, connectionId) {
1704
1715
  blocks: body.blocks,
1705
1716
  threadTs: body.threadTs ? String(body.threadTs) : void 0,
1706
1717
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1707
- tag: body.tag ? String(body.tag) : void 0
1718
+ tag: body.tag ? String(body.tag) : void 0,
1719
+ originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1720
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1708
1721
  },
1709
1722
  connectionId,
1710
1723
  sentAt: /* @__PURE__ */ new Date()
@@ -1742,18 +1755,33 @@ async function handleMessagingRequest(request, channel, subPath) {
1742
1755
  const userId = String(body.userId ?? "");
1743
1756
  const message = body.message;
1744
1757
  if (!userId) return failure("userId is required");
1745
- if (!message || message.type !== "text" || !message.text) {
1746
- return failure("message must be { type: 'text', text: string }");
1758
+ if (!message || message.type !== "text" && message.type !== "image") {
1759
+ return failure("message must be { type: 'text' | 'image', ... }");
1747
1760
  }
1748
- if (message.text.length > 5e3) {
1749
- return failure("message text exceeds maximum length");
1761
+ const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1762
+ if (message.type === "text") {
1763
+ if (!message.text) {
1764
+ return failure("message must be { type: 'text', text: string }");
1765
+ }
1766
+ if (message.text.length > 5e3) {
1767
+ return failure("message text exceeds maximum length");
1768
+ }
1769
+ captureMessage("line", userId, { text: message.text }, connectionId);
1770
+ } else {
1771
+ const originalContentUrl = message.originalContentUrl;
1772
+ if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1773
+ return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
1774
+ }
1775
+ if (message.previewImageUrl !== void 0 && !isValidLineImageUrl(message.previewImageUrl)) {
1776
+ return failure("previewImageUrl must be a valid HTTPS URL (max 2000 chars)");
1777
+ }
1778
+ captureMessage(
1779
+ "line",
1780
+ userId,
1781
+ { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1782
+ connectionId
1783
+ );
1750
1784
  }
1751
- captureMessage(
1752
- "line",
1753
- userId,
1754
- { text: message.text },
1755
- body.connectionId ? String(body.connectionId) : void 0
1756
- );
1757
1785
  return success({ success: true });
1758
1786
  }
1759
1787
  if (channel === "facebook" && subPath === "/send" && method === "POST") {
@@ -1851,6 +1879,44 @@ function buildBinding(alias, peripheralId) {
1851
1879
  updatedAt: now2()
1852
1880
  };
1853
1881
  }
1882
+ var DEFAULT_CAPABILITIES = {
1883
+ paperWidth: "80mm",
1884
+ columns: 48,
1885
+ dotsPerLine: 576,
1886
+ renderMode: "text",
1887
+ textEncoding: "utf8",
1888
+ unicodeText: false,
1889
+ thaiText: false,
1890
+ bold: true,
1891
+ textSizes: ["small", "normal", "large"],
1892
+ barcodeFormats: ["qr"],
1893
+ rasterImages: true,
1894
+ cut: true,
1895
+ cashDrawer: false
1896
+ };
1897
+ function handleCapabilities(request) {
1898
+ const url = new URL(request.url);
1899
+ const alias = url.searchParams.get("alias");
1900
+ const deviceId = url.searchParams.get("deviceId");
1901
+ let peripheralId = url.searchParams.get("peripheralId");
1902
+ if (alias) {
1903
+ const binding = state.edgeBindings.get(alias);
1904
+ if (!binding) return bindingNotFound(alias);
1905
+ peripheralId = binding.peripheral?.id ?? null;
1906
+ }
1907
+ if (peripheralId) {
1908
+ const peripheral = findPeripheral(peripheralId);
1909
+ if (!peripheral) {
1910
+ return edgeFailure("Peripheral not found or its device is not granted to this project", 404);
1911
+ }
1912
+ const seeded = state.edgePeripheralCapabilities.get(peripheralId);
1913
+ return success({ capabilities: seeded ?? state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1914
+ }
1915
+ if (deviceId && !state.edgeDevices.some((d) => d.id === deviceId)) {
1916
+ return edgeFailure("Project does not have access to this device", 403);
1917
+ }
1918
+ return success({ capabilities: state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1919
+ }
1854
1920
  function handleListDevices() {
1855
1921
  return success({ devices: [...state.edgeDevices] });
1856
1922
  }
@@ -1954,6 +2020,9 @@ async function handleEdgeRequest(request, subPath) {
1954
2020
  if (subPath === "/print" && method === "POST") {
1955
2021
  return handlePrint(request);
1956
2022
  }
2023
+ if (subPath === "/capabilities" && method === "GET") {
2024
+ return handleCapabilities(request);
2025
+ }
1957
2026
  if (subPath === "/devices" && method === "GET") {
1958
2027
  return handleListDevices();
1959
2028
  }
@@ -2015,7 +2084,17 @@ function createEdge() {
2015
2084
  state.edgeBindings.set(binding.alias, binding);
2016
2085
  }
2017
2086
  },
2087
+ seedCapabilities(capabilities, peripheralId) {
2088
+ if (peripheralId === void 0) {
2089
+ state.edgeCapabilities = capabilities;
2090
+ return;
2091
+ }
2092
+ if (capabilities === null) state.edgePeripheralCapabilities.delete(peripheralId);
2093
+ else state.edgePeripheralCapabilities.set(peripheralId, capabilities);
2094
+ },
2018
2095
  clear() {
2096
+ state.edgeCapabilities = null;
2097
+ state.edgePeripheralCapabilities.clear();
2019
2098
  state.edgePrints = [];
2020
2099
  state.edgeDisplays = [];
2021
2100
  state.edgeTestPrints = [];
package/dist/index.mjs CHANGED
@@ -71,6 +71,8 @@ var state = globalSingleton("state", () => ({
71
71
  edgeDevices: [],
72
72
  edgePeripherals: [],
73
73
  edgeBindings: /* @__PURE__ */ new Map(),
74
+ edgeCapabilities: null,
75
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
74
76
  allowNetwork: false
75
77
  }));
76
78
  function requireDb() {
@@ -1649,6 +1651,15 @@ function createStorage() {
1649
1651
 
1650
1652
  // src/simulator/messaging.ts
1651
1653
  import crypto7 from "crypto";
1654
+ function isValidLineImageUrl(url) {
1655
+ if (url.length > 2e3 || !url.startsWith("https://")) return false;
1656
+ try {
1657
+ new URL(url);
1658
+ return true;
1659
+ } catch {
1660
+ return false;
1661
+ }
1662
+ }
1652
1663
  function captureMessage(channel, recipient, body, connectionId) {
1653
1664
  const message = {
1654
1665
  channel,
@@ -1658,7 +1669,9 @@ function captureMessage(channel, recipient, body, connectionId) {
1658
1669
  blocks: body.blocks,
1659
1670
  threadTs: body.threadTs ? String(body.threadTs) : void 0,
1660
1671
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1661
- tag: body.tag ? String(body.tag) : void 0
1672
+ tag: body.tag ? String(body.tag) : void 0,
1673
+ originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1674
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1662
1675
  },
1663
1676
  connectionId,
1664
1677
  sentAt: /* @__PURE__ */ new Date()
@@ -1696,18 +1709,33 @@ async function handleMessagingRequest(request, channel, subPath) {
1696
1709
  const userId = String(body.userId ?? "");
1697
1710
  const message = body.message;
1698
1711
  if (!userId) return failure("userId is required");
1699
- if (!message || message.type !== "text" || !message.text) {
1700
- return failure("message must be { type: 'text', text: string }");
1712
+ if (!message || message.type !== "text" && message.type !== "image") {
1713
+ return failure("message must be { type: 'text' | 'image', ... }");
1701
1714
  }
1702
- if (message.text.length > 5e3) {
1703
- return failure("message text exceeds maximum length");
1715
+ const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1716
+ if (message.type === "text") {
1717
+ if (!message.text) {
1718
+ return failure("message must be { type: 'text', text: string }");
1719
+ }
1720
+ if (message.text.length > 5e3) {
1721
+ return failure("message text exceeds maximum length");
1722
+ }
1723
+ captureMessage("line", userId, { text: message.text }, connectionId);
1724
+ } else {
1725
+ const originalContentUrl = message.originalContentUrl;
1726
+ if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1727
+ return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
1728
+ }
1729
+ if (message.previewImageUrl !== void 0 && !isValidLineImageUrl(message.previewImageUrl)) {
1730
+ return failure("previewImageUrl must be a valid HTTPS URL (max 2000 chars)");
1731
+ }
1732
+ captureMessage(
1733
+ "line",
1734
+ userId,
1735
+ { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1736
+ connectionId
1737
+ );
1704
1738
  }
1705
- captureMessage(
1706
- "line",
1707
- userId,
1708
- { text: message.text },
1709
- body.connectionId ? String(body.connectionId) : void 0
1710
- );
1711
1739
  return success({ success: true });
1712
1740
  }
1713
1741
  if (channel === "facebook" && subPath === "/send" && method === "POST") {
@@ -1805,6 +1833,44 @@ function buildBinding(alias, peripheralId) {
1805
1833
  updatedAt: now2()
1806
1834
  };
1807
1835
  }
1836
+ var DEFAULT_CAPABILITIES = {
1837
+ paperWidth: "80mm",
1838
+ columns: 48,
1839
+ dotsPerLine: 576,
1840
+ renderMode: "text",
1841
+ textEncoding: "utf8",
1842
+ unicodeText: false,
1843
+ thaiText: false,
1844
+ bold: true,
1845
+ textSizes: ["small", "normal", "large"],
1846
+ barcodeFormats: ["qr"],
1847
+ rasterImages: true,
1848
+ cut: true,
1849
+ cashDrawer: false
1850
+ };
1851
+ function handleCapabilities(request) {
1852
+ const url = new URL(request.url);
1853
+ const alias = url.searchParams.get("alias");
1854
+ const deviceId = url.searchParams.get("deviceId");
1855
+ let peripheralId = url.searchParams.get("peripheralId");
1856
+ if (alias) {
1857
+ const binding = state.edgeBindings.get(alias);
1858
+ if (!binding) return bindingNotFound(alias);
1859
+ peripheralId = binding.peripheral?.id ?? null;
1860
+ }
1861
+ if (peripheralId) {
1862
+ const peripheral = findPeripheral(peripheralId);
1863
+ if (!peripheral) {
1864
+ return edgeFailure("Peripheral not found or its device is not granted to this project", 404);
1865
+ }
1866
+ const seeded = state.edgePeripheralCapabilities.get(peripheralId);
1867
+ return success({ capabilities: seeded ?? state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1868
+ }
1869
+ if (deviceId && !state.edgeDevices.some((d) => d.id === deviceId)) {
1870
+ return edgeFailure("Project does not have access to this device", 403);
1871
+ }
1872
+ return success({ capabilities: state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1873
+ }
1808
1874
  function handleListDevices() {
1809
1875
  return success({ devices: [...state.edgeDevices] });
1810
1876
  }
@@ -1908,6 +1974,9 @@ async function handleEdgeRequest(request, subPath) {
1908
1974
  if (subPath === "/print" && method === "POST") {
1909
1975
  return handlePrint(request);
1910
1976
  }
1977
+ if (subPath === "/capabilities" && method === "GET") {
1978
+ return handleCapabilities(request);
1979
+ }
1911
1980
  if (subPath === "/devices" && method === "GET") {
1912
1981
  return handleListDevices();
1913
1982
  }
@@ -1969,7 +2038,17 @@ function createEdge() {
1969
2038
  state.edgeBindings.set(binding.alias, binding);
1970
2039
  }
1971
2040
  },
2041
+ seedCapabilities(capabilities, peripheralId) {
2042
+ if (peripheralId === void 0) {
2043
+ state.edgeCapabilities = capabilities;
2044
+ return;
2045
+ }
2046
+ if (capabilities === null) state.edgePeripheralCapabilities.delete(peripheralId);
2047
+ else state.edgePeripheralCapabilities.set(peripheralId, capabilities);
2048
+ },
1972
2049
  clear() {
2050
+ state.edgeCapabilities = null;
2051
+ state.edgePeripheralCapabilities.clear();
1973
2052
  state.edgePrints = [];
1974
2053
  state.edgeDisplays = [];
1975
2054
  state.edgeTestPrints = [];
@@ -63,6 +63,8 @@ var state = globalSingleton("state", () => ({
63
63
  edgeDevices: [],
64
64
  edgePeripherals: [],
65
65
  edgeBindings: /* @__PURE__ */ new Map(),
66
+ edgeCapabilities: null,
67
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
66
68
  allowNetwork: false
67
69
  }));
68
70
 
@@ -36,6 +36,8 @@ var state = globalSingleton("state", () => ({
36
36
  edgeDevices: [],
37
37
  edgePeripherals: [],
38
38
  edgeBindings: /* @__PURE__ */ new Map(),
39
+ edgeCapabilities: null,
40
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
39
41
  allowNetwork: false
40
42
  }));
41
43
 
package/dist/setup.js CHANGED
@@ -57,6 +57,8 @@ var state = globalSingleton("state", () => ({
57
57
  edgeDevices: [],
58
58
  edgePeripherals: [],
59
59
  edgeBindings: /* @__PURE__ */ new Map(),
60
+ edgeCapabilities: null,
61
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
60
62
  allowNetwork: false
61
63
  }));
62
64
  function requireDb() {
@@ -1321,6 +1323,15 @@ async function handleStorageRequest(request, url) {
1321
1323
 
1322
1324
  // src/simulator/messaging.ts
1323
1325
  var import_node_crypto7 = __toESM(require("crypto"));
1326
+ function isValidLineImageUrl(url) {
1327
+ if (url.length > 2e3 || !url.startsWith("https://")) return false;
1328
+ try {
1329
+ new URL(url);
1330
+ return true;
1331
+ } catch {
1332
+ return false;
1333
+ }
1334
+ }
1324
1335
  function captureMessage(channel, recipient, body, connectionId) {
1325
1336
  const message = {
1326
1337
  channel,
@@ -1330,7 +1341,9 @@ function captureMessage(channel, recipient, body, connectionId) {
1330
1341
  blocks: body.blocks,
1331
1342
  threadTs: body.threadTs ? String(body.threadTs) : void 0,
1332
1343
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1333
- tag: body.tag ? String(body.tag) : void 0
1344
+ tag: body.tag ? String(body.tag) : void 0,
1345
+ originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1346
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1334
1347
  },
1335
1348
  connectionId,
1336
1349
  sentAt: /* @__PURE__ */ new Date()
@@ -1368,18 +1381,33 @@ async function handleMessagingRequest(request, channel, subPath) {
1368
1381
  const userId = String(body.userId ?? "");
1369
1382
  const message = body.message;
1370
1383
  if (!userId) return failure("userId is required");
1371
- if (!message || message.type !== "text" || !message.text) {
1372
- return failure("message must be { type: 'text', text: string }");
1384
+ if (!message || message.type !== "text" && message.type !== "image") {
1385
+ return failure("message must be { type: 'text' | 'image', ... }");
1373
1386
  }
1374
- if (message.text.length > 5e3) {
1375
- return failure("message text exceeds maximum length");
1387
+ const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1388
+ if (message.type === "text") {
1389
+ if (!message.text) {
1390
+ return failure("message must be { type: 'text', text: string }");
1391
+ }
1392
+ if (message.text.length > 5e3) {
1393
+ return failure("message text exceeds maximum length");
1394
+ }
1395
+ captureMessage("line", userId, { text: message.text }, connectionId);
1396
+ } else {
1397
+ const originalContentUrl = message.originalContentUrl;
1398
+ if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1399
+ return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
1400
+ }
1401
+ if (message.previewImageUrl !== void 0 && !isValidLineImageUrl(message.previewImageUrl)) {
1402
+ return failure("previewImageUrl must be a valid HTTPS URL (max 2000 chars)");
1403
+ }
1404
+ captureMessage(
1405
+ "line",
1406
+ userId,
1407
+ { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1408
+ connectionId
1409
+ );
1376
1410
  }
1377
- captureMessage(
1378
- "line",
1379
- userId,
1380
- { text: message.text },
1381
- body.connectionId ? String(body.connectionId) : void 0
1382
- );
1383
1411
  return success({ success: true });
1384
1412
  }
1385
1413
  if (channel === "facebook" && subPath === "/send" && method === "POST") {
@@ -1447,6 +1475,44 @@ function buildBinding(alias, peripheralId) {
1447
1475
  updatedAt: now2()
1448
1476
  };
1449
1477
  }
1478
+ var DEFAULT_CAPABILITIES = {
1479
+ paperWidth: "80mm",
1480
+ columns: 48,
1481
+ dotsPerLine: 576,
1482
+ renderMode: "text",
1483
+ textEncoding: "utf8",
1484
+ unicodeText: false,
1485
+ thaiText: false,
1486
+ bold: true,
1487
+ textSizes: ["small", "normal", "large"],
1488
+ barcodeFormats: ["qr"],
1489
+ rasterImages: true,
1490
+ cut: true,
1491
+ cashDrawer: false
1492
+ };
1493
+ function handleCapabilities(request) {
1494
+ const url = new URL(request.url);
1495
+ const alias = url.searchParams.get("alias");
1496
+ const deviceId = url.searchParams.get("deviceId");
1497
+ let peripheralId = url.searchParams.get("peripheralId");
1498
+ if (alias) {
1499
+ const binding = state.edgeBindings.get(alias);
1500
+ if (!binding) return bindingNotFound(alias);
1501
+ peripheralId = binding.peripheral?.id ?? null;
1502
+ }
1503
+ if (peripheralId) {
1504
+ const peripheral = findPeripheral(peripheralId);
1505
+ if (!peripheral) {
1506
+ return edgeFailure("Peripheral not found or its device is not granted to this project", 404);
1507
+ }
1508
+ const seeded = state.edgePeripheralCapabilities.get(peripheralId);
1509
+ return success({ capabilities: seeded ?? state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1510
+ }
1511
+ if (deviceId && !state.edgeDevices.some((d) => d.id === deviceId)) {
1512
+ return edgeFailure("Project does not have access to this device", 403);
1513
+ }
1514
+ return success({ capabilities: state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1515
+ }
1450
1516
  function handleListDevices() {
1451
1517
  return success({ devices: [...state.edgeDevices] });
1452
1518
  }
@@ -1550,6 +1616,9 @@ async function handleEdgeRequest(request, subPath) {
1550
1616
  if (subPath === "/print" && method === "POST") {
1551
1617
  return handlePrint(request);
1552
1618
  }
1619
+ if (subPath === "/capabilities" && method === "GET") {
1620
+ return handleCapabilities(request);
1621
+ }
1553
1622
  if (subPath === "/devices" && method === "GET") {
1554
1623
  return handleListDevices();
1555
1624
  }
package/dist/setup.mjs CHANGED
@@ -33,6 +33,8 @@ var state = globalSingleton("state", () => ({
33
33
  edgeDevices: [],
34
34
  edgePeripherals: [],
35
35
  edgeBindings: /* @__PURE__ */ new Map(),
36
+ edgeCapabilities: null,
37
+ edgePeripheralCapabilities: /* @__PURE__ */ new Map(),
36
38
  allowNetwork: false
37
39
  }));
38
40
  function requireDb() {
@@ -1297,6 +1299,15 @@ async function handleStorageRequest(request, url) {
1297
1299
 
1298
1300
  // src/simulator/messaging.ts
1299
1301
  import crypto7 from "crypto";
1302
+ function isValidLineImageUrl(url) {
1303
+ if (url.length > 2e3 || !url.startsWith("https://")) return false;
1304
+ try {
1305
+ new URL(url);
1306
+ return true;
1307
+ } catch {
1308
+ return false;
1309
+ }
1310
+ }
1300
1311
  function captureMessage(channel, recipient, body, connectionId) {
1301
1312
  const message = {
1302
1313
  channel,
@@ -1306,7 +1317,9 @@ function captureMessage(channel, recipient, body, connectionId) {
1306
1317
  blocks: body.blocks,
1307
1318
  threadTs: body.threadTs ? String(body.threadTs) : void 0,
1308
1319
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1309
- tag: body.tag ? String(body.tag) : void 0
1320
+ tag: body.tag ? String(body.tag) : void 0,
1321
+ originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1322
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1310
1323
  },
1311
1324
  connectionId,
1312
1325
  sentAt: /* @__PURE__ */ new Date()
@@ -1344,18 +1357,33 @@ async function handleMessagingRequest(request, channel, subPath) {
1344
1357
  const userId = String(body.userId ?? "");
1345
1358
  const message = body.message;
1346
1359
  if (!userId) return failure("userId is required");
1347
- if (!message || message.type !== "text" || !message.text) {
1348
- return failure("message must be { type: 'text', text: string }");
1360
+ if (!message || message.type !== "text" && message.type !== "image") {
1361
+ return failure("message must be { type: 'text' | 'image', ... }");
1349
1362
  }
1350
- if (message.text.length > 5e3) {
1351
- return failure("message text exceeds maximum length");
1363
+ const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1364
+ if (message.type === "text") {
1365
+ if (!message.text) {
1366
+ return failure("message must be { type: 'text', text: string }");
1367
+ }
1368
+ if (message.text.length > 5e3) {
1369
+ return failure("message text exceeds maximum length");
1370
+ }
1371
+ captureMessage("line", userId, { text: message.text }, connectionId);
1372
+ } else {
1373
+ const originalContentUrl = message.originalContentUrl;
1374
+ if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1375
+ return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
1376
+ }
1377
+ if (message.previewImageUrl !== void 0 && !isValidLineImageUrl(message.previewImageUrl)) {
1378
+ return failure("previewImageUrl must be a valid HTTPS URL (max 2000 chars)");
1379
+ }
1380
+ captureMessage(
1381
+ "line",
1382
+ userId,
1383
+ { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1384
+ connectionId
1385
+ );
1352
1386
  }
1353
- captureMessage(
1354
- "line",
1355
- userId,
1356
- { text: message.text },
1357
- body.connectionId ? String(body.connectionId) : void 0
1358
- );
1359
1387
  return success({ success: true });
1360
1388
  }
1361
1389
  if (channel === "facebook" && subPath === "/send" && method === "POST") {
@@ -1423,6 +1451,44 @@ function buildBinding(alias, peripheralId) {
1423
1451
  updatedAt: now2()
1424
1452
  };
1425
1453
  }
1454
+ var DEFAULT_CAPABILITIES = {
1455
+ paperWidth: "80mm",
1456
+ columns: 48,
1457
+ dotsPerLine: 576,
1458
+ renderMode: "text",
1459
+ textEncoding: "utf8",
1460
+ unicodeText: false,
1461
+ thaiText: false,
1462
+ bold: true,
1463
+ textSizes: ["small", "normal", "large"],
1464
+ barcodeFormats: ["qr"],
1465
+ rasterImages: true,
1466
+ cut: true,
1467
+ cashDrawer: false
1468
+ };
1469
+ function handleCapabilities(request) {
1470
+ const url = new URL(request.url);
1471
+ const alias = url.searchParams.get("alias");
1472
+ const deviceId = url.searchParams.get("deviceId");
1473
+ let peripheralId = url.searchParams.get("peripheralId");
1474
+ if (alias) {
1475
+ const binding = state.edgeBindings.get(alias);
1476
+ if (!binding) return bindingNotFound(alias);
1477
+ peripheralId = binding.peripheral?.id ?? null;
1478
+ }
1479
+ if (peripheralId) {
1480
+ const peripheral = findPeripheral(peripheralId);
1481
+ if (!peripheral) {
1482
+ return edgeFailure("Peripheral not found or its device is not granted to this project", 404);
1483
+ }
1484
+ const seeded = state.edgePeripheralCapabilities.get(peripheralId);
1485
+ return success({ capabilities: seeded ?? state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1486
+ }
1487
+ if (deviceId && !state.edgeDevices.some((d) => d.id === deviceId)) {
1488
+ return edgeFailure("Project does not have access to this device", 403);
1489
+ }
1490
+ return success({ capabilities: state.edgeCapabilities ?? DEFAULT_CAPABILITIES });
1491
+ }
1426
1492
  function handleListDevices() {
1427
1493
  return success({ devices: [...state.edgeDevices] });
1428
1494
  }
@@ -1526,6 +1592,9 @@ async function handleEdgeRequest(request, subPath) {
1526
1592
  if (subPath === "/print" && method === "POST") {
1527
1593
  return handlePrint(request);
1528
1594
  }
1595
+ if (subPath === "/capabilities" && method === "GET") {
1596
+ return handleCapabilities(request);
1597
+ }
1529
1598
  if (subPath === "/devices" && method === "GET") {
1530
1599
  return handleListDevices();
1531
1600
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -65,8 +65,8 @@
65
65
  "@electric-sql/pglite": "^0.3.0"
66
66
  },
67
67
  "peerDependencies": {
68
- "@stardeck-customer-apps/integrations-sdk": ">=1.6.0",
69
68
  "@stardeck-customer-apps/edge-sdk": ">=1.4.0",
69
+ "@stardeck-customer-apps/integrations-sdk": ">=1.6.0",
70
70
  "@stardeck-customer-apps/payments-sdk": ">=0.1.0",
71
71
  "@stardeck-customer-apps/storage-sdk": ">=0.1.0",
72
72
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
@@ -93,8 +93,8 @@
93
93
  "@eslint/js": "^9.0.0",
94
94
  "@neondatabase/serverless": "^1.0.0",
95
95
  "@stardeck-customer-apps/data-store-sdk": "*",
96
- "@stardeck-customer-apps/email-sdk": "*",
97
96
  "@stardeck-customer-apps/edge-sdk": "*",
97
+ "@stardeck-customer-apps/email-sdk": "*",
98
98
  "@stardeck-customer-apps/integrations-sdk": "*",
99
99
  "@stardeck-customer-apps/payments-sdk": "*",
100
100
  "@stardeck-customer-apps/storage-sdk": "*",
@@ -102,7 +102,7 @@
102
102
  "@types/node": "^24.10.1",
103
103
  "kysely": "^0.27.0",
104
104
  "kysely-neon": "^2.0.0",
105
- "next": "^15.0.0",
105
+ "next": "^16.2.12",
106
106
  "tsup": "^8.0.0",
107
107
  "typescript": "^5.0.0",
108
108
  "typescript-eslint": "^8.0.0",