@stardeck-customer-apps/testing 0.24.0 → 0.25.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
@@ -565,7 +565,11 @@ cursor, limit })`; `list()` intentionally keeps its previous array shape.
565
565
  in `app.storage` against the simulated `STORAGE_URL` host.
566
566
  - `client.slack` / `client.line` / `client.facebook` send endpoints — captured
567
567
  in `app.messages`.
568
- - `createEdgeClient()` (print, pair/unpair, list devices/peripherals, show/clear
568
+ - `createEdgeClient()` (print, `enqueueReceipt()`/`getPrintJob()` durable receipts: idempotent per key,
569
+ conflicts and refused options mirrored, `app.edge.durablePrints`, and
570
+ `app.edge.settleNextDurableJob({ status: "needs_attention", reason, outcome })` to drive the
571
+ uncertain/not-printed UI, or `{ status: "sent_to_printer", simulated: true }` to mirror a
572
+ virtual-printer job (the status then carries `simulated: true`); pair/unpair, list devices/peripherals, show/clear
569
573
  display, test print, `getCapabilities()`) — captured in `app.edge`; seed
570
574
  devices/peripherals for pairing pickers via `app.edge.seedDevices()` /
571
575
  `seedPeripherals()`.
package/dist/index.d.mts CHANGED
@@ -542,6 +542,29 @@ interface CapturedPrint {
542
542
  logo?: boolean;
543
543
  copies: number;
544
544
  }
545
+ /** One `edge.enqueueReceipt()` the app made, as the simulator accepted it. */
546
+ interface CapturedDurablePrint {
547
+ jobId: string;
548
+ idempotencyKey: string;
549
+ alias?: string;
550
+ deviceId?: string;
551
+ peripheralId?: string;
552
+ receipt: ReceiptPayload;
553
+ logo?: boolean;
554
+ }
555
+ /** How a simulated durable job settles. Mirrors the platform's status contract. */
556
+ type SimulatedDurableOutcome = {
557
+ status: "sent_to_printer";
558
+ simulated?: boolean;
559
+ } | {
560
+ status: "queued";
561
+ simulated?: boolean;
562
+ } | {
563
+ status: "needs_attention";
564
+ reason: string;
565
+ outcome: "not_printed" | "uncertain";
566
+ message?: string;
567
+ };
545
568
  interface CapturedDisplay {
546
569
  action: "show" | "clear";
547
570
  alias?: string;
@@ -557,6 +580,15 @@ interface TestEdge {
557
580
  get prints(): CapturedPrint[];
558
581
  get displays(): CapturedDisplay[];
559
582
  get testPrints(): CapturedTestPrint[];
583
+ /** Durable receipts accepted by `edge.enqueueReceipt()` (one per distinct idempotency key). */
584
+ get durablePrints(): CapturedDurablePrint[];
585
+ /**
586
+ * Decide how the NEXT accepted durable job settles when the app reads it with
587
+ * `edge.getPrintJob()`. Without this every job reads `sent_to_printer`. Queue
588
+ * several to model a run of jobs; use `needs_attention` with `uncertain` to
589
+ * test the "check the printer before reprinting" path.
590
+ */
591
+ settleNextDurableJob(outcome: SimulatedDurableOutcome): void;
560
592
  latestPrint(): CapturedPrint | undefined;
561
593
  latestDisplay(): CapturedDisplay | undefined;
562
594
  get bindings(): BindingInfo[];
@@ -742,4 +774,4 @@ declare const TEST_ENV_DEFAULTS: {
742
774
  /** Default location of the DDL snapshot written by `generate-types`. */
743
775
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
744
776
 
745
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
777
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedDurablePrint, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type SimulatedDurableOutcome, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.d.ts CHANGED
@@ -542,6 +542,29 @@ interface CapturedPrint {
542
542
  logo?: boolean;
543
543
  copies: number;
544
544
  }
545
+ /** One `edge.enqueueReceipt()` the app made, as the simulator accepted it. */
546
+ interface CapturedDurablePrint {
547
+ jobId: string;
548
+ idempotencyKey: string;
549
+ alias?: string;
550
+ deviceId?: string;
551
+ peripheralId?: string;
552
+ receipt: ReceiptPayload;
553
+ logo?: boolean;
554
+ }
555
+ /** How a simulated durable job settles. Mirrors the platform's status contract. */
556
+ type SimulatedDurableOutcome = {
557
+ status: "sent_to_printer";
558
+ simulated?: boolean;
559
+ } | {
560
+ status: "queued";
561
+ simulated?: boolean;
562
+ } | {
563
+ status: "needs_attention";
564
+ reason: string;
565
+ outcome: "not_printed" | "uncertain";
566
+ message?: string;
567
+ };
545
568
  interface CapturedDisplay {
546
569
  action: "show" | "clear";
547
570
  alias?: string;
@@ -557,6 +580,15 @@ interface TestEdge {
557
580
  get prints(): CapturedPrint[];
558
581
  get displays(): CapturedDisplay[];
559
582
  get testPrints(): CapturedTestPrint[];
583
+ /** Durable receipts accepted by `edge.enqueueReceipt()` (one per distinct idempotency key). */
584
+ get durablePrints(): CapturedDurablePrint[];
585
+ /**
586
+ * Decide how the NEXT accepted durable job settles when the app reads it with
587
+ * `edge.getPrintJob()`. Without this every job reads `sent_to_printer`. Queue
588
+ * several to model a run of jobs; use `needs_attention` with `uncertain` to
589
+ * test the "check the printer before reprinting" path.
590
+ */
591
+ settleNextDurableJob(outcome: SimulatedDurableOutcome): void;
560
592
  latestPrint(): CapturedPrint | undefined;
561
593
  latestDisplay(): CapturedDisplay | undefined;
562
594
  get bindings(): BindingInfo[];
@@ -742,4 +774,4 @@ declare const TEST_ENV_DEFAULTS: {
742
774
  /** Default location of the DDL snapshot written by `generate-types`. */
743
775
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
744
776
 
745
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
777
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedDurablePrint, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedStarlensCall, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PaymentSimulatorFailure, type PaymentSimulatorOperation, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, type SimulatedDurableOutcome, type StarlensReply, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStarlens, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.js CHANGED
@@ -365,6 +365,8 @@ var state = globalSingleton("state", () => ({
365
365
  edgeDisplays: [],
366
366
  edgeTestPrints: [],
367
367
  edgePrintCounter: 0,
368
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
369
+ edgeDurableOutcomes: [],
368
370
  edgeDevices: [],
369
371
  edgePeripherals: [],
370
372
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -3382,6 +3384,95 @@ async function handlePrint(request) {
3382
3384
  status: "completed"
3383
3385
  });
3384
3386
  }
3387
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
3388
+ async function handleEnqueue(request) {
3389
+ const body = await readJsonBody(request);
3390
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
3391
+ if (!IDEMPOTENCY_KEY.test(key)) {
3392
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
3393
+ }
3394
+ if (body.openDrawer === true) {
3395
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
3396
+ }
3397
+ if (body.copies !== void 0 && body.copies !== 1) {
3398
+ return edgeFailure(
3399
+ "Durable receipts print exactly one copy; submit a second job for another copy"
3400
+ );
3401
+ }
3402
+ const receipt = body.receipt ?? {};
3403
+ if (receipt.rawEscPos !== void 0) {
3404
+ return edgeFailure("rawEscPos is not supported for durable receipts");
3405
+ }
3406
+ const requestHash = JSON.stringify([
3407
+ body.alias,
3408
+ body.deviceId,
3409
+ body.peripheralId,
3410
+ receipt,
3411
+ body.logo
3412
+ ]);
3413
+ const existing = [...state.edgeDurableJobs.values()].find(
3414
+ (j) => j.captured.idempotencyKey === key
3415
+ );
3416
+ if (existing) {
3417
+ if (existing.requestHash !== requestHash) {
3418
+ return edgeFailure(
3419
+ "This idempotencyKey was already used for a different receipt, target or options.",
3420
+ 409,
3421
+ "IDEMPOTENCY_KEY_CONFLICT"
3422
+ );
3423
+ }
3424
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
3425
+ }
3426
+ const jobId = nextJobId();
3427
+ const captured = {
3428
+ jobId,
3429
+ idempotencyKey: key,
3430
+ alias: body.alias ? String(body.alias) : void 0,
3431
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
3432
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
3433
+ receipt,
3434
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
3435
+ };
3436
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
3437
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
3438
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
3439
+ }
3440
+ function durableView(jobId, as) {
3441
+ const job = state.edgeDurableJobs.get(jobId);
3442
+ const createdAt = now2();
3443
+ const simulated = job.outcome.status !== "needs_attention" && job.outcome.simulated ? { simulated: true } : {};
3444
+ const base = {
3445
+ ...simulated,
3446
+ jobId,
3447
+ attempts: as === "queued" ? 0 : 1,
3448
+ createdAt,
3449
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
3450
+ target: {
3451
+ deviceId: job.captured.deviceId ?? "sim-device",
3452
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
3453
+ }
3454
+ };
3455
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
3456
+ if (outcome.status === "needs_attention") {
3457
+ return {
3458
+ ...base,
3459
+ status: "needs_attention",
3460
+ attention: {
3461
+ reason: outcome.reason,
3462
+ outcome: outcome.outcome,
3463
+ message: outcome.message ?? "Delivery stopped."
3464
+ }
3465
+ };
3466
+ }
3467
+ if (outcome.status === "sent_to_printer") {
3468
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
3469
+ }
3470
+ return { ...base, status: "queued" };
3471
+ }
3472
+ function handleGetDurable(jobId) {
3473
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
3474
+ return success(durableView(jobId));
3475
+ }
3385
3476
  async function handleShowDisplay(request) {
3386
3477
  const body = await readJsonBody(request);
3387
3478
  const captured = {
@@ -3431,6 +3522,13 @@ async function handleEdgeRequest(request, subPath) {
3431
3522
  if (subPath === "/print" && method === "POST") {
3432
3523
  return handlePrint(request);
3433
3524
  }
3525
+ if (subPath === "/print-jobs" && method === "POST") {
3526
+ return handleEnqueue(request);
3527
+ }
3528
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
3529
+ if (durableMatch && method === "GET") {
3530
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
3531
+ }
3434
3532
  if (subPath === "/capabilities" && method === "GET") {
3435
3533
  return handleCapabilities(request);
3436
3534
  }
@@ -3474,6 +3572,12 @@ function createEdge() {
3474
3572
  get testPrints() {
3475
3573
  return [...state.edgeTestPrints];
3476
3574
  },
3575
+ get durablePrints() {
3576
+ return [...state.edgeDurableJobs.values()].map((j) => j.captured);
3577
+ },
3578
+ settleNextDurableJob(outcome) {
3579
+ state.edgeDurableOutcomes.push(outcome);
3580
+ },
3477
3581
  latestPrint() {
3478
3582
  return state.edgePrints[state.edgePrints.length - 1];
3479
3583
  },
@@ -3510,12 +3614,14 @@ function createEdge() {
3510
3614
  state.edgeDisplays = [];
3511
3615
  state.edgeTestPrints = [];
3512
3616
  state.edgePrintCounter = 0;
3617
+ state.edgeDurableJobs.clear();
3618
+ state.edgeDurableOutcomes = [];
3513
3619
  state.edgeDevices = [];
3514
3620
  state.edgePeripherals = [];
3515
3621
  state.edgeBindings.clear();
3516
3622
  },
3517
3623
  get count() {
3518
- return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length;
3624
+ return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length + state.edgeDurableJobs.size;
3519
3625
  }
3520
3626
  };
3521
3627
  }
package/dist/index.mjs CHANGED
@@ -326,6 +326,8 @@ var state = globalSingleton("state", () => ({
326
326
  edgeDisplays: [],
327
327
  edgeTestPrints: [],
328
328
  edgePrintCounter: 0,
329
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
330
+ edgeDurableOutcomes: [],
329
331
  edgeDevices: [],
330
332
  edgePeripherals: [],
331
333
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -3343,6 +3345,95 @@ async function handlePrint(request) {
3343
3345
  status: "completed"
3344
3346
  });
3345
3347
  }
3348
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
3349
+ async function handleEnqueue(request) {
3350
+ const body = await readJsonBody(request);
3351
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
3352
+ if (!IDEMPOTENCY_KEY.test(key)) {
3353
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
3354
+ }
3355
+ if (body.openDrawer === true) {
3356
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
3357
+ }
3358
+ if (body.copies !== void 0 && body.copies !== 1) {
3359
+ return edgeFailure(
3360
+ "Durable receipts print exactly one copy; submit a second job for another copy"
3361
+ );
3362
+ }
3363
+ const receipt = body.receipt ?? {};
3364
+ if (receipt.rawEscPos !== void 0) {
3365
+ return edgeFailure("rawEscPos is not supported for durable receipts");
3366
+ }
3367
+ const requestHash = JSON.stringify([
3368
+ body.alias,
3369
+ body.deviceId,
3370
+ body.peripheralId,
3371
+ receipt,
3372
+ body.logo
3373
+ ]);
3374
+ const existing = [...state.edgeDurableJobs.values()].find(
3375
+ (j) => j.captured.idempotencyKey === key
3376
+ );
3377
+ if (existing) {
3378
+ if (existing.requestHash !== requestHash) {
3379
+ return edgeFailure(
3380
+ "This idempotencyKey was already used for a different receipt, target or options.",
3381
+ 409,
3382
+ "IDEMPOTENCY_KEY_CONFLICT"
3383
+ );
3384
+ }
3385
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
3386
+ }
3387
+ const jobId = nextJobId();
3388
+ const captured = {
3389
+ jobId,
3390
+ idempotencyKey: key,
3391
+ alias: body.alias ? String(body.alias) : void 0,
3392
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
3393
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
3394
+ receipt,
3395
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
3396
+ };
3397
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
3398
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
3399
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
3400
+ }
3401
+ function durableView(jobId, as) {
3402
+ const job = state.edgeDurableJobs.get(jobId);
3403
+ const createdAt = now2();
3404
+ const simulated = job.outcome.status !== "needs_attention" && job.outcome.simulated ? { simulated: true } : {};
3405
+ const base = {
3406
+ ...simulated,
3407
+ jobId,
3408
+ attempts: as === "queued" ? 0 : 1,
3409
+ createdAt,
3410
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
3411
+ target: {
3412
+ deviceId: job.captured.deviceId ?? "sim-device",
3413
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
3414
+ }
3415
+ };
3416
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
3417
+ if (outcome.status === "needs_attention") {
3418
+ return {
3419
+ ...base,
3420
+ status: "needs_attention",
3421
+ attention: {
3422
+ reason: outcome.reason,
3423
+ outcome: outcome.outcome,
3424
+ message: outcome.message ?? "Delivery stopped."
3425
+ }
3426
+ };
3427
+ }
3428
+ if (outcome.status === "sent_to_printer") {
3429
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
3430
+ }
3431
+ return { ...base, status: "queued" };
3432
+ }
3433
+ function handleGetDurable(jobId) {
3434
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
3435
+ return success(durableView(jobId));
3436
+ }
3346
3437
  async function handleShowDisplay(request) {
3347
3438
  const body = await readJsonBody(request);
3348
3439
  const captured = {
@@ -3392,6 +3483,13 @@ async function handleEdgeRequest(request, subPath) {
3392
3483
  if (subPath === "/print" && method === "POST") {
3393
3484
  return handlePrint(request);
3394
3485
  }
3486
+ if (subPath === "/print-jobs" && method === "POST") {
3487
+ return handleEnqueue(request);
3488
+ }
3489
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
3490
+ if (durableMatch && method === "GET") {
3491
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
3492
+ }
3395
3493
  if (subPath === "/capabilities" && method === "GET") {
3396
3494
  return handleCapabilities(request);
3397
3495
  }
@@ -3435,6 +3533,12 @@ function createEdge() {
3435
3533
  get testPrints() {
3436
3534
  return [...state.edgeTestPrints];
3437
3535
  },
3536
+ get durablePrints() {
3537
+ return [...state.edgeDurableJobs.values()].map((j) => j.captured);
3538
+ },
3539
+ settleNextDurableJob(outcome) {
3540
+ state.edgeDurableOutcomes.push(outcome);
3541
+ },
3438
3542
  latestPrint() {
3439
3543
  return state.edgePrints[state.edgePrints.length - 1];
3440
3544
  },
@@ -3471,12 +3575,14 @@ function createEdge() {
3471
3575
  state.edgeDisplays = [];
3472
3576
  state.edgeTestPrints = [];
3473
3577
  state.edgePrintCounter = 0;
3578
+ state.edgeDurableJobs.clear();
3579
+ state.edgeDurableOutcomes = [];
3474
3580
  state.edgeDevices = [];
3475
3581
  state.edgePeripherals = [];
3476
3582
  state.edgeBindings.clear();
3477
3583
  },
3478
3584
  get count() {
3479
- return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length;
3585
+ return state.edgePrints.length + state.edgeDisplays.length + state.edgeTestPrints.length + state.edgeDurableJobs.size;
3480
3586
  }
3481
3587
  };
3482
3588
  }
@@ -87,6 +87,8 @@ var state = globalSingleton("state", () => ({
87
87
  edgeDisplays: [],
88
88
  edgeTestPrints: [],
89
89
  edgePrintCounter: 0,
90
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
91
+ edgeDurableOutcomes: [],
90
92
  edgeDevices: [],
91
93
  edgePeripherals: [],
92
94
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -60,6 +60,8 @@ var state = globalSingleton("state", () => ({
60
60
  edgeDisplays: [],
61
61
  edgeTestPrints: [],
62
62
  edgePrintCounter: 0,
63
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
64
+ edgeDurableOutcomes: [],
63
65
  edgeDevices: [],
64
66
  edgePeripherals: [],
65
67
  edgeBindings: /* @__PURE__ */ new Map(),
package/dist/setup.js CHANGED
@@ -92,6 +92,8 @@ var state = globalSingleton("state", () => ({
92
92
  edgeDisplays: [],
93
93
  edgeTestPrints: [],
94
94
  edgePrintCounter: 0,
95
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
96
+ edgeDurableOutcomes: [],
95
97
  edgeDevices: [],
96
98
  edgePeripherals: [],
97
99
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -2527,6 +2529,95 @@ async function handlePrint(request) {
2527
2529
  status: "completed"
2528
2530
  });
2529
2531
  }
2532
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
2533
+ async function handleEnqueue(request) {
2534
+ const body = await readJsonBody(request);
2535
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
2536
+ if (!IDEMPOTENCY_KEY.test(key)) {
2537
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
2538
+ }
2539
+ if (body.openDrawer === true) {
2540
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
2541
+ }
2542
+ if (body.copies !== void 0 && body.copies !== 1) {
2543
+ return edgeFailure(
2544
+ "Durable receipts print exactly one copy; submit a second job for another copy"
2545
+ );
2546
+ }
2547
+ const receipt = body.receipt ?? {};
2548
+ if (receipt.rawEscPos !== void 0) {
2549
+ return edgeFailure("rawEscPos is not supported for durable receipts");
2550
+ }
2551
+ const requestHash = JSON.stringify([
2552
+ body.alias,
2553
+ body.deviceId,
2554
+ body.peripheralId,
2555
+ receipt,
2556
+ body.logo
2557
+ ]);
2558
+ const existing = [...state.edgeDurableJobs.values()].find(
2559
+ (j) => j.captured.idempotencyKey === key
2560
+ );
2561
+ if (existing) {
2562
+ if (existing.requestHash !== requestHash) {
2563
+ return edgeFailure(
2564
+ "This idempotencyKey was already used for a different receipt, target or options.",
2565
+ 409,
2566
+ "IDEMPOTENCY_KEY_CONFLICT"
2567
+ );
2568
+ }
2569
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
2570
+ }
2571
+ const jobId = nextJobId();
2572
+ const captured = {
2573
+ jobId,
2574
+ idempotencyKey: key,
2575
+ alias: body.alias ? String(body.alias) : void 0,
2576
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
2577
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
2578
+ receipt,
2579
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
2580
+ };
2581
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
2582
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
2583
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
2584
+ }
2585
+ function durableView(jobId, as) {
2586
+ const job = state.edgeDurableJobs.get(jobId);
2587
+ const createdAt = now2();
2588
+ const simulated = job.outcome.status !== "needs_attention" && job.outcome.simulated ? { simulated: true } : {};
2589
+ const base = {
2590
+ ...simulated,
2591
+ jobId,
2592
+ attempts: as === "queued" ? 0 : 1,
2593
+ createdAt,
2594
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
2595
+ target: {
2596
+ deviceId: job.captured.deviceId ?? "sim-device",
2597
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
2598
+ }
2599
+ };
2600
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
2601
+ if (outcome.status === "needs_attention") {
2602
+ return {
2603
+ ...base,
2604
+ status: "needs_attention",
2605
+ attention: {
2606
+ reason: outcome.reason,
2607
+ outcome: outcome.outcome,
2608
+ message: outcome.message ?? "Delivery stopped."
2609
+ }
2610
+ };
2611
+ }
2612
+ if (outcome.status === "sent_to_printer") {
2613
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
2614
+ }
2615
+ return { ...base, status: "queued" };
2616
+ }
2617
+ function handleGetDurable(jobId) {
2618
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
2619
+ return success(durableView(jobId));
2620
+ }
2530
2621
  async function handleShowDisplay(request) {
2531
2622
  const body = await readJsonBody(request);
2532
2623
  const captured = {
@@ -2576,6 +2667,13 @@ async function handleEdgeRequest(request, subPath) {
2576
2667
  if (subPath === "/print" && method === "POST") {
2577
2668
  return handlePrint(request);
2578
2669
  }
2670
+ if (subPath === "/print-jobs" && method === "POST") {
2671
+ return handleEnqueue(request);
2672
+ }
2673
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
2674
+ if (durableMatch && method === "GET") {
2675
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
2676
+ }
2579
2677
  if (subPath === "/capabilities" && method === "GET") {
2580
2678
  return handleCapabilities(request);
2581
2679
  }
package/dist/setup.mjs CHANGED
@@ -68,6 +68,8 @@ var state = globalSingleton("state", () => ({
68
68
  edgeDisplays: [],
69
69
  edgeTestPrints: [],
70
70
  edgePrintCounter: 0,
71
+ edgeDurableJobs: /* @__PURE__ */ new Map(),
72
+ edgeDurableOutcomes: [],
71
73
  edgeDevices: [],
72
74
  edgePeripherals: [],
73
75
  edgeBindings: /* @__PURE__ */ new Map(),
@@ -2503,6 +2505,95 @@ async function handlePrint(request) {
2503
2505
  status: "completed"
2504
2506
  });
2505
2507
  }
2508
+ var IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,128}$/;
2509
+ async function handleEnqueue(request) {
2510
+ const body = await readJsonBody(request);
2511
+ const key = typeof body.idempotencyKey === "string" ? body.idempotencyKey : "";
2512
+ if (!IDEMPOTENCY_KEY.test(key)) {
2513
+ return edgeFailure("idempotencyKey must be 1-128 characters of A-Z a-z 0-9 _ . : -");
2514
+ }
2515
+ if (body.openDrawer === true) {
2516
+ return edgeFailure("Durable receipts cannot open the cash drawer; a retry would kick it again");
2517
+ }
2518
+ if (body.copies !== void 0 && body.copies !== 1) {
2519
+ return edgeFailure(
2520
+ "Durable receipts print exactly one copy; submit a second job for another copy"
2521
+ );
2522
+ }
2523
+ const receipt = body.receipt ?? {};
2524
+ if (receipt.rawEscPos !== void 0) {
2525
+ return edgeFailure("rawEscPos is not supported for durable receipts");
2526
+ }
2527
+ const requestHash = JSON.stringify([
2528
+ body.alias,
2529
+ body.deviceId,
2530
+ body.peripheralId,
2531
+ receipt,
2532
+ body.logo
2533
+ ]);
2534
+ const existing = [...state.edgeDurableJobs.values()].find(
2535
+ (j) => j.captured.idempotencyKey === key
2536
+ );
2537
+ if (existing) {
2538
+ if (existing.requestHash !== requestHash) {
2539
+ return edgeFailure(
2540
+ "This idempotencyKey was already used for a different receipt, target or options.",
2541
+ 409,
2542
+ "IDEMPOTENCY_KEY_CONFLICT"
2543
+ );
2544
+ }
2545
+ return json({ success: true, data: durableView(existing.captured.jobId, "queued") }, 200);
2546
+ }
2547
+ const jobId = nextJobId();
2548
+ const captured = {
2549
+ jobId,
2550
+ idempotencyKey: key,
2551
+ alias: body.alias ? String(body.alias) : void 0,
2552
+ deviceId: body.deviceId ? String(body.deviceId) : void 0,
2553
+ peripheralId: body.peripheralId ? String(body.peripheralId) : void 0,
2554
+ receipt,
2555
+ logo: body.logo === true ? true : body.logo === false ? false : void 0
2556
+ };
2557
+ const outcome = state.edgeDurableOutcomes.shift() ?? { status: "sent_to_printer" };
2558
+ state.edgeDurableJobs.set(jobId, { requestHash, captured, outcome });
2559
+ return json({ success: true, data: durableView(jobId, "queued") }, 202);
2560
+ }
2561
+ function durableView(jobId, as) {
2562
+ const job = state.edgeDurableJobs.get(jobId);
2563
+ const createdAt = now2();
2564
+ const simulated = job.outcome.status !== "needs_attention" && job.outcome.simulated ? { simulated: true } : {};
2565
+ const base = {
2566
+ ...simulated,
2567
+ jobId,
2568
+ attempts: as === "queued" ? 0 : 1,
2569
+ createdAt,
2570
+ expiresAt: new Date(Date.parse(createdAt) + 10 * 6e4).toISOString(),
2571
+ target: {
2572
+ deviceId: job.captured.deviceId ?? "sim-device",
2573
+ peripheralId: job.captured.peripheralId ?? "sim-peripheral"
2574
+ }
2575
+ };
2576
+ const outcome = as === "queued" ? { status: "queued" } : job.outcome;
2577
+ if (outcome.status === "needs_attention") {
2578
+ return {
2579
+ ...base,
2580
+ status: "needs_attention",
2581
+ attention: {
2582
+ reason: outcome.reason,
2583
+ outcome: outcome.outcome,
2584
+ message: outcome.message ?? "Delivery stopped."
2585
+ }
2586
+ };
2587
+ }
2588
+ if (outcome.status === "sent_to_printer") {
2589
+ return { ...base, status: "sent_to_printer", sentToPrinterAt: createdAt };
2590
+ }
2591
+ return { ...base, status: "queued" };
2592
+ }
2593
+ function handleGetDurable(jobId) {
2594
+ if (!state.edgeDurableJobs.has(jobId)) return edgeFailure("Print job not found", 404);
2595
+ return success(durableView(jobId));
2596
+ }
2506
2597
  async function handleShowDisplay(request) {
2507
2598
  const body = await readJsonBody(request);
2508
2599
  const captured = {
@@ -2552,6 +2643,13 @@ async function handleEdgeRequest(request, subPath) {
2552
2643
  if (subPath === "/print" && method === "POST") {
2553
2644
  return handlePrint(request);
2554
2645
  }
2646
+ if (subPath === "/print-jobs" && method === "POST") {
2647
+ return handleEnqueue(request);
2648
+ }
2649
+ const durableMatch = subPath.match(/^\/print-jobs\/([^/]+)$/);
2650
+ if (durableMatch && method === "GET") {
2651
+ return handleGetDurable(decodeURIComponent(durableMatch[1]));
2652
+ }
2555
2653
  if (subPath === "/capabilities" && method === "GET") {
2556
2654
  return handleCapabilities(request);
2557
2655
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",