@stardeck-customer-apps/testing 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -29,6 +29,65 @@ This writes `src/generated/data-store-types.ts` and
29
29
  `src/generated/data-store-schema.sql`. The harness applies the SQL snapshot to
30
30
  PGlite at test boot. **Commit both files.**
31
31
 
32
+ ### Data store bindings
33
+
34
+ Configure each app-local data store binding once in `vitest.config.ts`. The
35
+ harness injects the same `STARDECK_DATA_STORES` manifest shape as production, so
36
+ application code resolves the binding without test-only branches:
37
+
38
+ ```ts
39
+ import { defineStardeckTestConfig } from "@stardeck-customer-apps/testing/config";
40
+
41
+ export default defineStardeckTestConfig(
42
+ {},
43
+ {
44
+ dataStores: [{ bindingKey: "development" }],
45
+ }
46
+ );
47
+ ```
48
+
49
+ ```ts
50
+ import { createDataStore } from "@stardeck-customer-apps/data-store-sdk/server";
51
+
52
+ const db = await createDataStore({ storeName: "development" });
53
+ ```
54
+
55
+ Do not branch on `DATA_STORE_URL?.includes(".stardeck.test")` or otherwise
56
+ sniff test hosts. Configure the binding and use `createDataStore({ storeName })`
57
+ the same way in tests and production.
58
+
59
+ For one test file that needs a different manifest, pass the same entries to the
60
+ app instead. This overrides the config-level manifest for that file:
61
+
62
+ ```ts
63
+ app = await createTestApp({
64
+ dataStores: [
65
+ { bindingKey: "development" },
66
+ { bindingKey: "uploads", name: "Customer Files", storeType: "storage" },
67
+ ],
68
+ });
69
+ ```
70
+
71
+ An app setup file may also set `STARDECK_DATA_STORES` directly. The harness
72
+ adopts a non-empty raw env value when `createTestApp()` has no `dataStores`
73
+ option; a per-app `dataStores` option still overrides it. An empty env value is
74
+ treated as unset, matching the SDK manifest parser.
75
+
76
+ Strictness follows adoption:
77
+
78
+ - With no manifest configuration, `createTestApp()` injects a `main` database
79
+ entry and keeps `DATA_STORE_URL` for older bare `createDataStore()` calls.
80
+ - Configuring `dataStores` through either channel removes `DATA_STORE_URL` by
81
+ default. A misspelled `storeName` then fails instead of silently connecting to
82
+ the default database.
83
+ - Passing `dataStores: []` explicitly creates a strict zero-store test world:
84
+ the manifest is `[]`, `DATA_STORE_URL` is absent, and every
85
+ `createDataStore(...)` call fails until a store is configured.
86
+ - Empty-string `bindingKey`, `slug`, or `name` values throw during test app
87
+ setup so typos cannot silently change manifest resolution.
88
+ - Set `legacyDataStoreUrl: true` per file only while migrating legacy code. Set
89
+ it to `false` to test strict manifest-only resolution even with zero config.
90
+
32
91
  ## Writing tests
33
92
 
34
93
  ```ts
@@ -144,6 +203,26 @@ const integrations = createIntegrationsClient();
144
203
  await integrations.line.push("U123", { type: "text", text: "Your order shipped" });
145
204
  expect(app.messages.channel("line").to("U123")[0].body.text).toMatch(/shipped/i);
146
205
 
206
+ // LINE Flex card — the simulator preserves the complete LINE-native container.
207
+ const card = {
208
+ type: "bubble" as const,
209
+ body: {
210
+ type: "box",
211
+ layout: "vertical",
212
+ contents: [{ type: "text", text: "Order shipped" }],
213
+ },
214
+ };
215
+ await integrations.line.push("U123", {
216
+ type: "flex",
217
+ altText: "Your order shipped",
218
+ contents: card,
219
+ });
220
+ expect(app.messages.channel("line").to("U123")[1].body).toEqual({
221
+ type: "flex",
222
+ altText: "Your order shipped",
223
+ contents: card,
224
+ });
225
+
147
226
  // Guest/counter identity resolution. `provenance` is selected by this trusted
148
227
  // server route; never copy it from a browser form or request body.
149
228
  const identity = await integrations.identities.resolveOrCreate({
@@ -186,7 +265,14 @@ expect(app.edge.latestDisplay()?.action).toBe("show");
186
265
 
187
266
  - `createTestApp(options)` — boots PGlite + the control-plane simulator and
188
267
  sets all Stardeck env vars (`CONTROL_PLANE_URL`, `DEPLOYMENT_SECRET`,
189
- `DATA_STORE_URL`, ids). One per test file, in `beforeAll`.
268
+ `STARDECK_DATA_STORES`, ids, and the legacy `DATA_STORE_URL` when enabled).
269
+ One per test file, in `beforeAll`.
270
+ - `dataStores` — per-file production-shaped data store manifest entries. The
271
+ first entry defaults to slug `main`; later entries need a `slug` or `name`.
272
+ Pass `[]` for strict zero-store mode. Empty `bindingKey`, `slug`, and `name`
273
+ values are rejected.
274
+ - `legacyDataStoreUrl` — force the legacy single-store URL on or off. When
275
+ omitted, it is on only for zero-config tests and off after manifest adoption.
190
276
  - `schema` — path to the DDL snapshot (default
191
277
  `./src/generated/data-store-schema.sql`); `null` for apps without a store.
192
278
  - `schemaSql` — inline DDL instead of a file.
@@ -222,7 +308,10 @@ externalId })` helper models a trusted platform/channel link, and
222
308
  list `limit` 1–100, status filter values).
223
309
  - `app.storage` — uploads through storage-sdk: `.uploads`, `.latest()`, `.count`, `.clear()`.
224
310
  - `app.messages` — outbound Slack/LINE/Facebook sends:
225
- `.all()`, `.latest()`, `.to(recipient)`, `.channel("line")`, `.count`, `.clear()`.
311
+ `.all()`, `.latest()`, `.to(recipient)`, `.channel("line")`, `.count`, `.clear()`. LINE Flex
312
+ captures expose `body.type`, `body.altText`, and the complete `body.contents` bubble/carousel.
313
+ The simulator validates the Flex envelope like production; LINE itself remains responsible for
314
+ validating nested component layout.
226
315
  - `app.edge` — edge print/display/bindings from edge-sdk: `.prints`, `.displays`,
227
316
  `.testPrints`, `.latestPrint()`, `.latestDisplay()`, `.bindings`,
228
317
  `.seedDevices()`, `.seedPeripherals()`, `.seedBindings()`,
package/dist/config.d.mts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { ViteUserConfig } from 'vitest/config';
2
+ import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.mjs';
2
3
 
4
+ interface StardeckTestConfigOptions {
5
+ dataStores?: TestDataStoreInput[];
6
+ }
3
7
  /**
4
8
  * Vitest config for Stardeck customer apps. Aliases `next/headers` (and
5
9
  * `server-only`) to harness shims so server code runs outside a Next request
@@ -11,6 +15,6 @@ import { ViteUserConfig } from 'vitest/config';
11
15
  * export default defineStardeckTestConfig();
12
16
  * ```
13
17
  */
14
- declare function defineStardeckTestConfig(overrides?: ViteUserConfig): ViteUserConfig;
18
+ declare function defineStardeckTestConfig(overrides?: ViteUserConfig, stardeck?: StardeckTestConfigOptions): ViteUserConfig;
15
19
 
16
- export { defineStardeckTestConfig };
20
+ export { type StardeckTestConfigOptions, defineStardeckTestConfig };
package/dist/config.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { ViteUserConfig } from 'vitest/config';
2
+ import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.js';
2
3
 
4
+ interface StardeckTestConfigOptions {
5
+ dataStores?: TestDataStoreInput[];
6
+ }
3
7
  /**
4
8
  * Vitest config for Stardeck customer apps. Aliases `next/headers` (and
5
9
  * `server-only`) to harness shims so server code runs outside a Next request
@@ -11,6 +15,6 @@ import { ViteUserConfig } from 'vitest/config';
11
15
  * export default defineStardeckTestConfig();
12
16
  * ```
13
17
  */
14
- declare function defineStardeckTestConfig(overrides?: ViteUserConfig): ViteUserConfig;
18
+ declare function defineStardeckTestConfig(overrides?: ViteUserConfig, stardeck?: StardeckTestConfigOptions): ViteUserConfig;
15
19
 
16
- export { defineStardeckTestConfig };
20
+ export { type StardeckTestConfigOptions, defineStardeckTestConfig };
package/dist/config.js CHANGED
@@ -23,7 +23,77 @@ __export(config_exports, {
23
23
  defineStardeckTestConfig: () => defineStardeckTestConfig
24
24
  });
25
25
  module.exports = __toCommonJS(config_exports);
26
- function defineStardeckTestConfig(overrides = {}) {
26
+
27
+ // src/constants.ts
28
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
29
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
30
+
31
+ // src/data-store-manifest.ts
32
+ var STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
33
+ function dataStoreSlug(name) {
34
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
35
+ return slug || "store";
36
+ }
37
+ function buildTestDataStoreManifest(inputs = [{}]) {
38
+ const takenSlugs = /* @__PURE__ */ new Set();
39
+ const takenBindingKeys = /* @__PURE__ */ new Set();
40
+ const takenIds = /* @__PURE__ */ new Set();
41
+ return inputs.map((input, index) => {
42
+ for (const field of ["bindingKey", "slug", "name"]) {
43
+ if (input[field] === "") {
44
+ throw new Error(`[stardeck-testing] dataStores[${index}].${field} cannot be empty.`);
45
+ }
46
+ }
47
+ let slug;
48
+ if (input.slug !== void 0) {
49
+ slug = input.slug;
50
+ if (takenSlugs.has(slug)) {
51
+ throw new Error(`[stardeck-testing] dataStores[${index}].slug must be unique.`);
52
+ }
53
+ } else {
54
+ const base = input.name !== void 0 ? dataStoreSlug(input.name) : index === 0 ? "main" : null;
55
+ if (base === null) {
56
+ throw new Error(
57
+ `[stardeck-testing] dataStores[${index}] requires a slug or name. Only the first entry defaults to the "main" slug.`
58
+ );
59
+ }
60
+ slug = base;
61
+ let suffix = 2;
62
+ while (takenSlugs.has(slug)) {
63
+ slug = `${base}_${suffix}`;
64
+ suffix++;
65
+ }
66
+ }
67
+ const id = input.id ?? `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
68
+ if (takenIds.has(id)) {
69
+ throw new Error(`[stardeck-testing] dataStores[${index}].id must be unique.`);
70
+ }
71
+ if (input.bindingKey !== void 0 && takenBindingKeys.has(input.bindingKey)) {
72
+ throw new Error(`[stardeck-testing] dataStores[${index}].bindingKey must be unique.`);
73
+ }
74
+ takenSlugs.add(slug);
75
+ takenIds.add(id);
76
+ if (input.bindingKey !== void 0) takenBindingKeys.add(input.bindingKey);
77
+ const storeType = input.storeType ?? "database";
78
+ return {
79
+ id,
80
+ name: input.name ?? slug,
81
+ slug,
82
+ storeType,
83
+ accessLevel: input.accessLevel ?? "admin",
84
+ ...storeType === "database" ? { url: input.url ?? DATA_STORE_TEST_URL } : {},
85
+ ...input.bindingKey ? { bindingKey: input.bindingKey } : {}
86
+ };
87
+ });
88
+ }
89
+
90
+ // src/config.ts
91
+ function defineStardeckTestConfig(overrides = {}, stardeck = {}) {
92
+ const dataStoreEnv = stardeck.dataStores !== void 0 ? {
93
+ [STARDECK_DATA_STORES_ENV]: JSON.stringify(
94
+ buildTestDataStoreManifest(stardeck.dataStores)
95
+ )
96
+ } : {};
27
97
  return {
28
98
  ...overrides,
29
99
  resolve: {
@@ -44,6 +114,10 @@ function defineStardeckTestConfig(overrides = {}) {
44
114
  pool: "forks",
45
115
  isolate: true,
46
116
  ...overrides.test,
117
+ env: {
118
+ ...dataStoreEnv,
119
+ ...overrides.test?.env
120
+ },
47
121
  setupFiles: [
48
122
  "@stardeck-customer-apps/testing/setup",
49
123
  ...overrides.test?.setupFiles ? Array.isArray(overrides.test.setupFiles) ? overrides.test.setupFiles : [overrides.test.setupFiles] : []
package/dist/config.mjs CHANGED
@@ -1,5 +1,73 @@
1
+ // src/constants.ts
2
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
3
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
4
+
5
+ // src/data-store-manifest.ts
6
+ var STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
7
+ function dataStoreSlug(name) {
8
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
9
+ return slug || "store";
10
+ }
11
+ function buildTestDataStoreManifest(inputs = [{}]) {
12
+ const takenSlugs = /* @__PURE__ */ new Set();
13
+ const takenBindingKeys = /* @__PURE__ */ new Set();
14
+ const takenIds = /* @__PURE__ */ new Set();
15
+ return inputs.map((input, index) => {
16
+ for (const field of ["bindingKey", "slug", "name"]) {
17
+ if (input[field] === "") {
18
+ throw new Error(`[stardeck-testing] dataStores[${index}].${field} cannot be empty.`);
19
+ }
20
+ }
21
+ let slug;
22
+ if (input.slug !== void 0) {
23
+ slug = input.slug;
24
+ if (takenSlugs.has(slug)) {
25
+ throw new Error(`[stardeck-testing] dataStores[${index}].slug must be unique.`);
26
+ }
27
+ } else {
28
+ const base = input.name !== void 0 ? dataStoreSlug(input.name) : index === 0 ? "main" : null;
29
+ if (base === null) {
30
+ throw new Error(
31
+ `[stardeck-testing] dataStores[${index}] requires a slug or name. Only the first entry defaults to the "main" slug.`
32
+ );
33
+ }
34
+ slug = base;
35
+ let suffix = 2;
36
+ while (takenSlugs.has(slug)) {
37
+ slug = `${base}_${suffix}`;
38
+ suffix++;
39
+ }
40
+ }
41
+ const id = input.id ?? `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
42
+ if (takenIds.has(id)) {
43
+ throw new Error(`[stardeck-testing] dataStores[${index}].id must be unique.`);
44
+ }
45
+ if (input.bindingKey !== void 0 && takenBindingKeys.has(input.bindingKey)) {
46
+ throw new Error(`[stardeck-testing] dataStores[${index}].bindingKey must be unique.`);
47
+ }
48
+ takenSlugs.add(slug);
49
+ takenIds.add(id);
50
+ if (input.bindingKey !== void 0) takenBindingKeys.add(input.bindingKey);
51
+ const storeType = input.storeType ?? "database";
52
+ return {
53
+ id,
54
+ name: input.name ?? slug,
55
+ slug,
56
+ storeType,
57
+ accessLevel: input.accessLevel ?? "admin",
58
+ ...storeType === "database" ? { url: input.url ?? DATA_STORE_TEST_URL } : {},
59
+ ...input.bindingKey ? { bindingKey: input.bindingKey } : {}
60
+ };
61
+ });
62
+ }
63
+
1
64
  // src/config.ts
2
- function defineStardeckTestConfig(overrides = {}) {
65
+ function defineStardeckTestConfig(overrides = {}, stardeck = {}) {
66
+ const dataStoreEnv = stardeck.dataStores !== void 0 ? {
67
+ [STARDECK_DATA_STORES_ENV]: JSON.stringify(
68
+ buildTestDataStoreManifest(stardeck.dataStores)
69
+ )
70
+ } : {};
3
71
  return {
4
72
  ...overrides,
5
73
  resolve: {
@@ -20,6 +88,10 @@ function defineStardeckTestConfig(overrides = {}) {
20
88
  pool: "forks",
21
89
  isolate: true,
22
90
  ...overrides.test,
91
+ env: {
92
+ ...dataStoreEnv,
93
+ ...overrides.test?.env
94
+ },
23
95
  setupFiles: [
24
96
  "@stardeck-customer-apps/testing/setup",
25
97
  ...overrides.test?.setupFiles ? Array.isArray(overrides.test.setupFiles) ? overrides.test.setupFiles : [overrides.test.setupFiles] : []
@@ -0,0 +1,25 @@
1
+ declare const STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
2
+ interface TestDataStoreInput {
3
+ bindingKey?: string;
4
+ /** Stable snake_case handle for the store. */
5
+ slug?: string;
6
+ name?: string;
7
+ id?: string;
8
+ accessLevel?: "read" | "write" | "admin";
9
+ storeType?: "database" | "storage";
10
+ /** Defaults to the PGlite simulator URL for database stores. */
11
+ url?: string;
12
+ }
13
+ interface TestDataStoreManifestEntry {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ storeType: "database" | "storage";
18
+ accessLevel: "read" | "write" | "admin";
19
+ url?: string;
20
+ bindingKey?: string;
21
+ }
22
+ /** Builds the production-shaped manifest injected into a test app. */
23
+ declare function buildTestDataStoreManifest(inputs?: TestDataStoreInput[]): TestDataStoreManifestEntry[];
24
+
25
+ export { STARDECK_DATA_STORES_ENV as S, type TestDataStoreInput as T, type TestDataStoreManifestEntry as a, buildTestDataStoreManifest as b };
@@ -0,0 +1,25 @@
1
+ declare const STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
2
+ interface TestDataStoreInput {
3
+ bindingKey?: string;
4
+ /** Stable snake_case handle for the store. */
5
+ slug?: string;
6
+ name?: string;
7
+ id?: string;
8
+ accessLevel?: "read" | "write" | "admin";
9
+ storeType?: "database" | "storage";
10
+ /** Defaults to the PGlite simulator URL for database stores. */
11
+ url?: string;
12
+ }
13
+ interface TestDataStoreManifestEntry {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ storeType: "database" | "storage";
18
+ accessLevel: "read" | "write" | "admin";
19
+ url?: string;
20
+ bindingKey?: string;
21
+ }
22
+ /** Builds the production-shaped manifest injected into a test app. */
23
+ declare function buildTestDataStoreManifest(inputs?: TestDataStoreInput[]): TestDataStoreManifestEntry[];
24
+
25
+ export { STARDECK_DATA_STORES_ENV as S, type TestDataStoreInput as T, type TestDataStoreManifestEntry as a, buildTestDataStoreManifest as b };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as _stardeck_customer_apps_payments_sdk from '@stardeck-customer-apps/payments-sdk';
2
2
  import * as next_server from 'next/server';
3
3
  import { PGlite } from '@electric-sql/pglite';
4
+ import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.mjs';
5
+ export { S as STARDECK_DATA_STORES_ENV, a as TestDataStoreManifestEntry, b as buildTestDataStoreManifest } from './data-store-manifest-Zwgcv0DJ.mjs';
4
6
 
5
7
  type RouteHandler<Req extends Request = Request, P = Record<string, string | string[]>> = (request: Req, context: {
6
8
  params: Promise<P>;
@@ -250,6 +252,7 @@ interface CapturedMessage {
250
252
  channel: "slack" | "line" | "facebook";
251
253
  recipient: string;
252
254
  body: {
255
+ type?: string;
253
256
  text?: string;
254
257
  blocks?: unknown[];
255
258
  threadTs?: string;
@@ -259,6 +262,10 @@ interface CapturedMessage {
259
262
  originalContentUrl?: string;
260
263
  /** LINE image push — HTTPS URL of the preview; may be omitted when not sent. */
261
264
  previewImageUrl?: string;
265
+ /** LINE Flex fallback text shown when the rich card cannot be rendered. */
266
+ altText?: string;
267
+ /** Complete LINE Flex bubble or carousel payload. */
268
+ contents?: Record<string, unknown>;
262
269
  };
263
270
  connectionId?: string;
264
271
  sentAt: Date;
@@ -437,6 +444,13 @@ interface TestEdge {
437
444
  get count(): number;
438
445
  }
439
446
  interface TestAppOptions {
447
+ /** Data stores exposed through the production-shaped STARDECK_DATA_STORES manifest. */
448
+ dataStores?: TestDataStoreInput[];
449
+ /**
450
+ * Force the legacy single-store DATA_STORE_URL on or off. By default it is
451
+ * present only for the zero-config manifest, and omitted once a manifest is configured.
452
+ */
453
+ legacyDataStoreUrl?: boolean;
440
454
  /**
441
455
  * Path to the schema.sql snapshot generated by
442
456
  * `npx stardeck-data-store generate-types` (relative to the project root).
@@ -517,6 +531,7 @@ declare function parseWorkflowName(describeTitle: string): string | null;
517
531
 
518
532
  declare const CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
519
533
  declare const DATA_STORE_TEST_HOST = "db.stardeck.test";
534
+ declare const DATA_STORE_TEST_URL = "postgresql://test:test@db.stardeck.test/main";
520
535
  declare const STORAGE_TEST_URL = "https://storage.stardeck.test";
521
536
  declare const STORAGE_TEST_HOST = "storage.stardeck.test";
522
537
  declare const TEST_ENV_DEFAULTS: {
@@ -525,10 +540,9 @@ declare const TEST_ENV_DEFAULTS: {
525
540
  readonly ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a";
526
541
  readonly PROJECT_ID: "00000000-0000-4000-8000-00000000000b";
527
542
  readonly DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c";
528
- readonly DATA_STORE_URL: "postgresql://test:test@db.stardeck.test/main";
529
543
  readonly STORAGE_URL: "https://storage.stardeck.test";
530
544
  };
531
545
  /** Default location of the DDL snapshot written by `generate-types`. */
532
546
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
533
547
 
534
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
548
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as _stardeck_customer_apps_payments_sdk from '@stardeck-customer-apps/payments-sdk';
2
2
  import * as next_server from 'next/server';
3
3
  import { PGlite } from '@electric-sql/pglite';
4
+ import { T as TestDataStoreInput } from './data-store-manifest-Zwgcv0DJ.js';
5
+ export { S as STARDECK_DATA_STORES_ENV, a as TestDataStoreManifestEntry, b as buildTestDataStoreManifest } from './data-store-manifest-Zwgcv0DJ.js';
4
6
 
5
7
  type RouteHandler<Req extends Request = Request, P = Record<string, string | string[]>> = (request: Req, context: {
6
8
  params: Promise<P>;
@@ -250,6 +252,7 @@ interface CapturedMessage {
250
252
  channel: "slack" | "line" | "facebook";
251
253
  recipient: string;
252
254
  body: {
255
+ type?: string;
253
256
  text?: string;
254
257
  blocks?: unknown[];
255
258
  threadTs?: string;
@@ -259,6 +262,10 @@ interface CapturedMessage {
259
262
  originalContentUrl?: string;
260
263
  /** LINE image push — HTTPS URL of the preview; may be omitted when not sent. */
261
264
  previewImageUrl?: string;
265
+ /** LINE Flex fallback text shown when the rich card cannot be rendered. */
266
+ altText?: string;
267
+ /** Complete LINE Flex bubble or carousel payload. */
268
+ contents?: Record<string, unknown>;
262
269
  };
263
270
  connectionId?: string;
264
271
  sentAt: Date;
@@ -437,6 +444,13 @@ interface TestEdge {
437
444
  get count(): number;
438
445
  }
439
446
  interface TestAppOptions {
447
+ /** Data stores exposed through the production-shaped STARDECK_DATA_STORES manifest. */
448
+ dataStores?: TestDataStoreInput[];
449
+ /**
450
+ * Force the legacy single-store DATA_STORE_URL on or off. By default it is
451
+ * present only for the zero-config manifest, and omitted once a manifest is configured.
452
+ */
453
+ legacyDataStoreUrl?: boolean;
440
454
  /**
441
455
  * Path to the schema.sql snapshot generated by
442
456
  * `npx stardeck-data-store generate-types` (relative to the project root).
@@ -517,6 +531,7 @@ declare function parseWorkflowName(describeTitle: string): string | null;
517
531
 
518
532
  declare const CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
519
533
  declare const DATA_STORE_TEST_HOST = "db.stardeck.test";
534
+ declare const DATA_STORE_TEST_URL = "postgresql://test:test@db.stardeck.test/main";
520
535
  declare const STORAGE_TEST_URL = "https://storage.stardeck.test";
521
536
  declare const STORAGE_TEST_HOST = "storage.stardeck.test";
522
537
  declare const TEST_ENV_DEFAULTS: {
@@ -525,10 +540,9 @@ declare const TEST_ENV_DEFAULTS: {
525
540
  readonly ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a";
526
541
  readonly PROJECT_ID: "00000000-0000-4000-8000-00000000000b";
527
542
  readonly DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c";
528
- readonly DATA_STORE_URL: "postgresql://test:test@db.stardeck.test/main";
529
543
  readonly STORAGE_URL: "https://storage.stardeck.test";
530
544
  };
531
545
  /** Default location of the DDL snapshot written by `generate-types`. */
532
546
  declare const DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
533
547
 
534
- export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
548
+ export { type BindingInfo, CONTROL_PLANE_TEST_URL, type CallRouteOptions, type CapturedCheckout, type CapturedDisplay, type CapturedEmail, type CapturedIdentity, type CapturedIdentityLink, type CapturedMessage, type CapturedPrint, type CapturedTestPrint, type CapturedUpload, DATA_STORE_TEST_HOST, DATA_STORE_TEST_URL, DEFAULT_SCHEMA_PATH, type DeviceInfo, type PeripheralInfo, STORAGE_TEST_HOST, STORAGE_TEST_URL, type SessionTokens, type SimBoltCharge, type SimBoltConnection, type SimBoltIntentRecord, type SimBoltIntentStatus, TEST_ENV_DEFAULTS, type TestApp, type TestAppOptions, TestDataStoreInput, type TestDirectory, type TestEdge, type TestInbox, type TestMessages, type TestPayments, type TestStorage, type TestUser, WORKFLOW_NAME_PREFIX, callRoute, createTestApp, describeWorkflow, parseWorkflowName };
package/dist/index.js CHANGED
@@ -32,11 +32,14 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CONTROL_PLANE_TEST_URL: () => CONTROL_PLANE_TEST_URL,
34
34
  DATA_STORE_TEST_HOST: () => DATA_STORE_TEST_HOST,
35
+ DATA_STORE_TEST_URL: () => DATA_STORE_TEST_URL,
35
36
  DEFAULT_SCHEMA_PATH: () => DEFAULT_SCHEMA_PATH,
37
+ STARDECK_DATA_STORES_ENV: () => STARDECK_DATA_STORES_ENV,
36
38
  STORAGE_TEST_HOST: () => STORAGE_TEST_HOST,
37
39
  STORAGE_TEST_URL: () => STORAGE_TEST_URL,
38
40
  TEST_ENV_DEFAULTS: () => TEST_ENV_DEFAULTS,
39
41
  WORKFLOW_NAME_PREFIX: () => WORKFLOW_NAME_PREFIX,
42
+ buildTestDataStoreManifest: () => buildTestDataStoreManifest,
40
43
  callRoute: () => callRoute,
41
44
  createTestApp: () => createTestApp,
42
45
  describeWorkflow: () => describeWorkflow,
@@ -93,6 +96,7 @@ function globalSingleton(key, create) {
93
96
  // src/state.ts
94
97
  var state = globalSingleton("state", () => ({
95
98
  db: null,
99
+ lastHarnessDataStoreManifest: null,
96
100
  currentUser: null,
97
101
  sessions: /* @__PURE__ */ new Map(),
98
102
  refreshSessions: /* @__PURE__ */ new Map(),
@@ -141,6 +145,7 @@ function requireDb() {
141
145
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
142
146
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
143
147
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
148
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
144
149
  var STORAGE_TEST_URL = "https://storage.stardeck.test";
145
150
  var STORAGE_TEST_HOST = "storage.stardeck.test";
146
151
  var TEST_ENV_DEFAULTS = {
@@ -149,7 +154,6 @@ var TEST_ENV_DEFAULTS = {
149
154
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
150
155
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
151
156
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
152
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
153
157
  STORAGE_URL: STORAGE_TEST_URL
154
158
  };
155
159
  var DEFAULT_TEST_USER = {
@@ -2068,6 +2072,9 @@ function isValidLineImageUrl(url) {
2068
2072
  return false;
2069
2073
  }
2070
2074
  }
2075
+ function isObject(value) {
2076
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2077
+ }
2071
2078
  function captureMessage(channel, recipient, body, connectionId) {
2072
2079
  const message = {
2073
2080
  channel,
@@ -2079,7 +2086,8 @@ function captureMessage(channel, recipient, body, connectionId) {
2079
2086
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
2080
2087
  tag: body.tag ? String(body.tag) : void 0,
2081
2088
  originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
2082
- previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
2089
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0,
2090
+ ...body.type === "flex" && isObject(body.contents) ? { type: "flex", altText: String(body.altText), contents: body.contents } : {}
2083
2091
  },
2084
2092
  connectionId,
2085
2093
  sentAt: /* @__PURE__ */ new Date()
@@ -2117,8 +2125,8 @@ async function handleMessagingRequest(request, channel, subPath) {
2117
2125
  const userId = String(body.userId ?? "");
2118
2126
  const message = body.message;
2119
2127
  if (!userId) return failure("userId is required");
2120
- if (!message || message.type !== "text" && message.type !== "image") {
2121
- return failure("message must be { type: 'text' | 'image', ... }");
2128
+ if (!message || message.type !== "text" && message.type !== "image" && message.type !== "flex") {
2129
+ return failure("message must be { type: 'text' | 'image' | 'flex', ... }");
2122
2130
  }
2123
2131
  const connectionId = body.connectionId ? String(body.connectionId) : void 0;
2124
2132
  if (message.type === "text") {
@@ -2129,7 +2137,7 @@ async function handleMessagingRequest(request, channel, subPath) {
2129
2137
  return failure("message text exceeds maximum length");
2130
2138
  }
2131
2139
  captureMessage("line", userId, { text: message.text }, connectionId);
2132
- } else {
2140
+ } else if (message.type === "image") {
2133
2141
  const originalContentUrl = message.originalContentUrl;
2134
2142
  if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
2135
2143
  return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
@@ -2140,7 +2148,23 @@ async function handleMessagingRequest(request, channel, subPath) {
2140
2148
  captureMessage(
2141
2149
  "line",
2142
2150
  userId,
2143
- { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
2151
+ {
2152
+ originalContentUrl,
2153
+ previewImageUrl: message.previewImageUrl ?? originalContentUrl
2154
+ },
2155
+ connectionId
2156
+ );
2157
+ } else {
2158
+ if (typeof message.altText !== "string" || !message.altText || message.altText.length > 400) {
2159
+ return failure("altText must be between 1 and 400 characters");
2160
+ }
2161
+ if (!isObject(message.contents) || message.contents.type !== "bubble" && message.contents.type !== "carousel") {
2162
+ return failure("contents.type must be 'bubble' or 'carousel'");
2163
+ }
2164
+ captureMessage(
2165
+ "line",
2166
+ userId,
2167
+ { type: "flex", altText: message.altText, contents: message.contents },
2144
2168
  connectionId
2145
2169
  );
2146
2170
  }
@@ -2603,11 +2627,89 @@ function uninstallFetchRouter() {
2603
2627
  }
2604
2628
  }
2605
2629
 
2630
+ // src/data-store-manifest.ts
2631
+ var STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
2632
+ function dataStoreSlug(name) {
2633
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2634
+ return slug || "store";
2635
+ }
2636
+ function buildTestDataStoreManifest(inputs = [{}]) {
2637
+ const takenSlugs = /* @__PURE__ */ new Set();
2638
+ const takenBindingKeys = /* @__PURE__ */ new Set();
2639
+ const takenIds = /* @__PURE__ */ new Set();
2640
+ return inputs.map((input, index) => {
2641
+ for (const field of ["bindingKey", "slug", "name"]) {
2642
+ if (input[field] === "") {
2643
+ throw new Error(`[stardeck-testing] dataStores[${index}].${field} cannot be empty.`);
2644
+ }
2645
+ }
2646
+ let slug;
2647
+ if (input.slug !== void 0) {
2648
+ slug = input.slug;
2649
+ if (takenSlugs.has(slug)) {
2650
+ throw new Error(`[stardeck-testing] dataStores[${index}].slug must be unique.`);
2651
+ }
2652
+ } else {
2653
+ const base = input.name !== void 0 ? dataStoreSlug(input.name) : index === 0 ? "main" : null;
2654
+ if (base === null) {
2655
+ throw new Error(
2656
+ `[stardeck-testing] dataStores[${index}] requires a slug or name. Only the first entry defaults to the "main" slug.`
2657
+ );
2658
+ }
2659
+ slug = base;
2660
+ let suffix = 2;
2661
+ while (takenSlugs.has(slug)) {
2662
+ slug = `${base}_${suffix}`;
2663
+ suffix++;
2664
+ }
2665
+ }
2666
+ const id = input.id ?? `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
2667
+ if (takenIds.has(id)) {
2668
+ throw new Error(`[stardeck-testing] dataStores[${index}].id must be unique.`);
2669
+ }
2670
+ if (input.bindingKey !== void 0 && takenBindingKeys.has(input.bindingKey)) {
2671
+ throw new Error(`[stardeck-testing] dataStores[${index}].bindingKey must be unique.`);
2672
+ }
2673
+ takenSlugs.add(slug);
2674
+ takenIds.add(id);
2675
+ if (input.bindingKey !== void 0) takenBindingKeys.add(input.bindingKey);
2676
+ const storeType = input.storeType ?? "database";
2677
+ return {
2678
+ id,
2679
+ name: input.name ?? slug,
2680
+ slug,
2681
+ storeType,
2682
+ accessLevel: input.accessLevel ?? "admin",
2683
+ ...storeType === "database" ? { url: input.url ?? DATA_STORE_TEST_URL } : {},
2684
+ ...input.bindingKey ? { bindingKey: input.bindingKey } : {}
2685
+ };
2686
+ });
2687
+ }
2688
+
2606
2689
  // src/test-app.ts
2607
- function applyTestEnv() {
2690
+ function applyTestEnv(options) {
2608
2691
  for (const [key, value] of Object.entries(TEST_ENV_DEFAULTS)) {
2609
2692
  process.env[key] = value;
2610
2693
  }
2694
+ let manifestAdopted = true;
2695
+ if (options.dataStores !== void 0) {
2696
+ const manifest = JSON.stringify(buildTestDataStoreManifest(options.dataStores));
2697
+ process.env[STARDECK_DATA_STORES_ENV] = manifest;
2698
+ state.lastHarnessDataStoreManifest = manifest;
2699
+ } else {
2700
+ const currentManifest = process.env[STARDECK_DATA_STORES_ENV];
2701
+ if (!currentManifest || currentManifest === state.lastHarnessDataStoreManifest) {
2702
+ const manifest = JSON.stringify(buildTestDataStoreManifest());
2703
+ process.env[STARDECK_DATA_STORES_ENV] = manifest;
2704
+ state.lastHarnessDataStoreManifest = manifest;
2705
+ manifestAdopted = false;
2706
+ }
2707
+ }
2708
+ if (options.legacyDataStoreUrl === true || !manifestAdopted && options.legacyDataStoreUrl === void 0) {
2709
+ process.env.DATA_STORE_URL = DATA_STORE_TEST_URL;
2710
+ } else {
2711
+ delete process.env.DATA_STORE_URL;
2712
+ }
2611
2713
  delete process.env.DISABLE_AUTH;
2612
2714
  delete process.env.NEXT_PUBLIC_DISABLE_AUTH;
2613
2715
  delete process.env.SANDBOX_MODE;
@@ -2640,7 +2742,7 @@ async function createTestApp(options = {}) {
2640
2742
  "[stardeck-testing] A test app is already active in this test file. Call `app.close()` first, or share one app per file."
2641
2743
  );
2642
2744
  }
2643
- applyTestEnv();
2745
+ applyTestEnv(options);
2644
2746
  state.allowNetwork = options.allowNetwork ?? false;
2645
2747
  installFetchRouter();
2646
2748
  const db = await createPgliteDatabase();
@@ -2742,11 +2844,14 @@ function parseWorkflowName(describeTitle) {
2742
2844
  0 && (module.exports = {
2743
2845
  CONTROL_PLANE_TEST_URL,
2744
2846
  DATA_STORE_TEST_HOST,
2847
+ DATA_STORE_TEST_URL,
2745
2848
  DEFAULT_SCHEMA_PATH,
2849
+ STARDECK_DATA_STORES_ENV,
2746
2850
  STORAGE_TEST_HOST,
2747
2851
  STORAGE_TEST_URL,
2748
2852
  TEST_ENV_DEFAULTS,
2749
2853
  WORKFLOW_NAME_PREFIX,
2854
+ buildTestDataStoreManifest,
2750
2855
  callRoute,
2751
2856
  createTestApp,
2752
2857
  describeWorkflow,
package/dist/index.mjs CHANGED
@@ -47,6 +47,7 @@ function globalSingleton(key, create) {
47
47
  // src/state.ts
48
48
  var state = globalSingleton("state", () => ({
49
49
  db: null,
50
+ lastHarnessDataStoreManifest: null,
50
51
  currentUser: null,
51
52
  sessions: /* @__PURE__ */ new Map(),
52
53
  refreshSessions: /* @__PURE__ */ new Map(),
@@ -95,6 +96,7 @@ function requireDb() {
95
96
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
96
97
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
97
98
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
99
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
98
100
  var STORAGE_TEST_URL = "https://storage.stardeck.test";
99
101
  var STORAGE_TEST_HOST = "storage.stardeck.test";
100
102
  var TEST_ENV_DEFAULTS = {
@@ -103,7 +105,6 @@ var TEST_ENV_DEFAULTS = {
103
105
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
104
106
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
105
107
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
106
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
107
108
  STORAGE_URL: STORAGE_TEST_URL
108
109
  };
109
110
  var DEFAULT_TEST_USER = {
@@ -2022,6 +2023,9 @@ function isValidLineImageUrl(url) {
2022
2023
  return false;
2023
2024
  }
2024
2025
  }
2026
+ function isObject(value) {
2027
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2028
+ }
2025
2029
  function captureMessage(channel, recipient, body, connectionId) {
2026
2030
  const message = {
2027
2031
  channel,
@@ -2033,7 +2037,8 @@ function captureMessage(channel, recipient, body, connectionId) {
2033
2037
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
2034
2038
  tag: body.tag ? String(body.tag) : void 0,
2035
2039
  originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
2036
- previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
2040
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0,
2041
+ ...body.type === "flex" && isObject(body.contents) ? { type: "flex", altText: String(body.altText), contents: body.contents } : {}
2037
2042
  },
2038
2043
  connectionId,
2039
2044
  sentAt: /* @__PURE__ */ new Date()
@@ -2071,8 +2076,8 @@ async function handleMessagingRequest(request, channel, subPath) {
2071
2076
  const userId = String(body.userId ?? "");
2072
2077
  const message = body.message;
2073
2078
  if (!userId) return failure("userId is required");
2074
- if (!message || message.type !== "text" && message.type !== "image") {
2075
- return failure("message must be { type: 'text' | 'image', ... }");
2079
+ if (!message || message.type !== "text" && message.type !== "image" && message.type !== "flex") {
2080
+ return failure("message must be { type: 'text' | 'image' | 'flex', ... }");
2076
2081
  }
2077
2082
  const connectionId = body.connectionId ? String(body.connectionId) : void 0;
2078
2083
  if (message.type === "text") {
@@ -2083,7 +2088,7 @@ async function handleMessagingRequest(request, channel, subPath) {
2083
2088
  return failure("message text exceeds maximum length");
2084
2089
  }
2085
2090
  captureMessage("line", userId, { text: message.text }, connectionId);
2086
- } else {
2091
+ } else if (message.type === "image") {
2087
2092
  const originalContentUrl = message.originalContentUrl;
2088
2093
  if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
2089
2094
  return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
@@ -2094,7 +2099,23 @@ async function handleMessagingRequest(request, channel, subPath) {
2094
2099
  captureMessage(
2095
2100
  "line",
2096
2101
  userId,
2097
- { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
2102
+ {
2103
+ originalContentUrl,
2104
+ previewImageUrl: message.previewImageUrl ?? originalContentUrl
2105
+ },
2106
+ connectionId
2107
+ );
2108
+ } else {
2109
+ if (typeof message.altText !== "string" || !message.altText || message.altText.length > 400) {
2110
+ return failure("altText must be between 1 and 400 characters");
2111
+ }
2112
+ if (!isObject(message.contents) || message.contents.type !== "bubble" && message.contents.type !== "carousel") {
2113
+ return failure("contents.type must be 'bubble' or 'carousel'");
2114
+ }
2115
+ captureMessage(
2116
+ "line",
2117
+ userId,
2118
+ { type: "flex", altText: message.altText, contents: message.contents },
2098
2119
  connectionId
2099
2120
  );
2100
2121
  }
@@ -2557,11 +2578,89 @@ function uninstallFetchRouter() {
2557
2578
  }
2558
2579
  }
2559
2580
 
2581
+ // src/data-store-manifest.ts
2582
+ var STARDECK_DATA_STORES_ENV = "STARDECK_DATA_STORES";
2583
+ function dataStoreSlug(name) {
2584
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2585
+ return slug || "store";
2586
+ }
2587
+ function buildTestDataStoreManifest(inputs = [{}]) {
2588
+ const takenSlugs = /* @__PURE__ */ new Set();
2589
+ const takenBindingKeys = /* @__PURE__ */ new Set();
2590
+ const takenIds = /* @__PURE__ */ new Set();
2591
+ return inputs.map((input, index) => {
2592
+ for (const field of ["bindingKey", "slug", "name"]) {
2593
+ if (input[field] === "") {
2594
+ throw new Error(`[stardeck-testing] dataStores[${index}].${field} cannot be empty.`);
2595
+ }
2596
+ }
2597
+ let slug;
2598
+ if (input.slug !== void 0) {
2599
+ slug = input.slug;
2600
+ if (takenSlugs.has(slug)) {
2601
+ throw new Error(`[stardeck-testing] dataStores[${index}].slug must be unique.`);
2602
+ }
2603
+ } else {
2604
+ const base = input.name !== void 0 ? dataStoreSlug(input.name) : index === 0 ? "main" : null;
2605
+ if (base === null) {
2606
+ throw new Error(
2607
+ `[stardeck-testing] dataStores[${index}] requires a slug or name. Only the first entry defaults to the "main" slug.`
2608
+ );
2609
+ }
2610
+ slug = base;
2611
+ let suffix = 2;
2612
+ while (takenSlugs.has(slug)) {
2613
+ slug = `${base}_${suffix}`;
2614
+ suffix++;
2615
+ }
2616
+ }
2617
+ const id = input.id ?? `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
2618
+ if (takenIds.has(id)) {
2619
+ throw new Error(`[stardeck-testing] dataStores[${index}].id must be unique.`);
2620
+ }
2621
+ if (input.bindingKey !== void 0 && takenBindingKeys.has(input.bindingKey)) {
2622
+ throw new Error(`[stardeck-testing] dataStores[${index}].bindingKey must be unique.`);
2623
+ }
2624
+ takenSlugs.add(slug);
2625
+ takenIds.add(id);
2626
+ if (input.bindingKey !== void 0) takenBindingKeys.add(input.bindingKey);
2627
+ const storeType = input.storeType ?? "database";
2628
+ return {
2629
+ id,
2630
+ name: input.name ?? slug,
2631
+ slug,
2632
+ storeType,
2633
+ accessLevel: input.accessLevel ?? "admin",
2634
+ ...storeType === "database" ? { url: input.url ?? DATA_STORE_TEST_URL } : {},
2635
+ ...input.bindingKey ? { bindingKey: input.bindingKey } : {}
2636
+ };
2637
+ });
2638
+ }
2639
+
2560
2640
  // src/test-app.ts
2561
- function applyTestEnv() {
2641
+ function applyTestEnv(options) {
2562
2642
  for (const [key, value] of Object.entries(TEST_ENV_DEFAULTS)) {
2563
2643
  process.env[key] = value;
2564
2644
  }
2645
+ let manifestAdopted = true;
2646
+ if (options.dataStores !== void 0) {
2647
+ const manifest = JSON.stringify(buildTestDataStoreManifest(options.dataStores));
2648
+ process.env[STARDECK_DATA_STORES_ENV] = manifest;
2649
+ state.lastHarnessDataStoreManifest = manifest;
2650
+ } else {
2651
+ const currentManifest = process.env[STARDECK_DATA_STORES_ENV];
2652
+ if (!currentManifest || currentManifest === state.lastHarnessDataStoreManifest) {
2653
+ const manifest = JSON.stringify(buildTestDataStoreManifest());
2654
+ process.env[STARDECK_DATA_STORES_ENV] = manifest;
2655
+ state.lastHarnessDataStoreManifest = manifest;
2656
+ manifestAdopted = false;
2657
+ }
2658
+ }
2659
+ if (options.legacyDataStoreUrl === true || !manifestAdopted && options.legacyDataStoreUrl === void 0) {
2660
+ process.env.DATA_STORE_URL = DATA_STORE_TEST_URL;
2661
+ } else {
2662
+ delete process.env.DATA_STORE_URL;
2663
+ }
2565
2664
  delete process.env.DISABLE_AUTH;
2566
2665
  delete process.env.NEXT_PUBLIC_DISABLE_AUTH;
2567
2666
  delete process.env.SANDBOX_MODE;
@@ -2594,7 +2693,7 @@ async function createTestApp(options = {}) {
2594
2693
  "[stardeck-testing] A test app is already active in this test file. Call `app.close()` first, or share one app per file."
2595
2694
  );
2596
2695
  }
2597
- applyTestEnv();
2696
+ applyTestEnv(options);
2598
2697
  state.allowNetwork = options.allowNetwork ?? false;
2599
2698
  installFetchRouter();
2600
2699
  const db = await createPgliteDatabase();
@@ -2695,11 +2794,14 @@ function parseWorkflowName(describeTitle) {
2695
2794
  export {
2696
2795
  CONTROL_PLANE_TEST_URL,
2697
2796
  DATA_STORE_TEST_HOST,
2797
+ DATA_STORE_TEST_URL,
2698
2798
  DEFAULT_SCHEMA_PATH,
2799
+ STARDECK_DATA_STORES_ENV,
2699
2800
  STORAGE_TEST_HOST,
2700
2801
  STORAGE_TEST_URL,
2701
2802
  TEST_ENV_DEFAULTS,
2702
2803
  WORKFLOW_NAME_PREFIX,
2804
+ buildTestDataStoreManifest,
2703
2805
  callRoute,
2704
2806
  createTestApp,
2705
2807
  describeWorkflow,
@@ -39,6 +39,7 @@ function globalSingleton(key, create) {
39
39
  // src/state.ts
40
40
  var state = globalSingleton("state", () => ({
41
41
  db: null,
42
+ lastHarnessDataStoreManifest: null,
42
43
  currentUser: null,
43
44
  sessions: /* @__PURE__ */ new Map(),
44
45
  refreshSessions: /* @__PURE__ */ new Map(),
@@ -12,6 +12,7 @@ function globalSingleton(key, create) {
12
12
  // src/state.ts
13
13
  var state = globalSingleton("state", () => ({
14
14
  db: null,
15
+ lastHarnessDataStoreManifest: null,
15
16
  currentUser: null,
16
17
  sessions: /* @__PURE__ */ new Map(),
17
18
  refreshSessions: /* @__PURE__ */ new Map(),
package/dist/setup.js CHANGED
@@ -33,6 +33,7 @@ function globalSingleton(key, create) {
33
33
  // src/state.ts
34
34
  var state = globalSingleton("state", () => ({
35
35
  db: null,
36
+ lastHarnessDataStoreManifest: null,
36
37
  currentUser: null,
37
38
  sessions: /* @__PURE__ */ new Map(),
38
39
  refreshSessions: /* @__PURE__ */ new Map(),
@@ -81,6 +82,7 @@ function requireDb() {
81
82
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
82
83
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
83
84
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
85
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
84
86
  var STORAGE_TEST_URL = "https://storage.stardeck.test";
85
87
  var STORAGE_TEST_HOST = "storage.stardeck.test";
86
88
  var TEST_ENV_DEFAULTS = {
@@ -89,7 +91,6 @@ var TEST_ENV_DEFAULTS = {
89
91
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
90
92
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
91
93
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
92
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
93
94
  STORAGE_URL: STORAGE_TEST_URL
94
95
  };
95
96
 
@@ -1621,6 +1622,9 @@ function isValidLineImageUrl(url) {
1621
1622
  return false;
1622
1623
  }
1623
1624
  }
1625
+ function isObject(value) {
1626
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1627
+ }
1624
1628
  function captureMessage(channel, recipient, body, connectionId) {
1625
1629
  const message = {
1626
1630
  channel,
@@ -1632,7 +1636,8 @@ function captureMessage(channel, recipient, body, connectionId) {
1632
1636
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1633
1637
  tag: body.tag ? String(body.tag) : void 0,
1634
1638
  originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1635
- previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1639
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0,
1640
+ ...body.type === "flex" && isObject(body.contents) ? { type: "flex", altText: String(body.altText), contents: body.contents } : {}
1636
1641
  },
1637
1642
  connectionId,
1638
1643
  sentAt: /* @__PURE__ */ new Date()
@@ -1670,8 +1675,8 @@ async function handleMessagingRequest(request, channel, subPath) {
1670
1675
  const userId = String(body.userId ?? "");
1671
1676
  const message = body.message;
1672
1677
  if (!userId) return failure("userId is required");
1673
- if (!message || message.type !== "text" && message.type !== "image") {
1674
- return failure("message must be { type: 'text' | 'image', ... }");
1678
+ if (!message || message.type !== "text" && message.type !== "image" && message.type !== "flex") {
1679
+ return failure("message must be { type: 'text' | 'image' | 'flex', ... }");
1675
1680
  }
1676
1681
  const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1677
1682
  if (message.type === "text") {
@@ -1682,7 +1687,7 @@ async function handleMessagingRequest(request, channel, subPath) {
1682
1687
  return failure("message text exceeds maximum length");
1683
1688
  }
1684
1689
  captureMessage("line", userId, { text: message.text }, connectionId);
1685
- } else {
1690
+ } else if (message.type === "image") {
1686
1691
  const originalContentUrl = message.originalContentUrl;
1687
1692
  if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1688
1693
  return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
@@ -1693,7 +1698,23 @@ async function handleMessagingRequest(request, channel, subPath) {
1693
1698
  captureMessage(
1694
1699
  "line",
1695
1700
  userId,
1696
- { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1701
+ {
1702
+ originalContentUrl,
1703
+ previewImageUrl: message.previewImageUrl ?? originalContentUrl
1704
+ },
1705
+ connectionId
1706
+ );
1707
+ } else {
1708
+ if (typeof message.altText !== "string" || !message.altText || message.altText.length > 400) {
1709
+ return failure("altText must be between 1 and 400 characters");
1710
+ }
1711
+ if (!isObject(message.contents) || message.contents.type !== "bubble" && message.contents.type !== "carousel") {
1712
+ return failure("contents.type must be 'bubble' or 'carousel'");
1713
+ }
1714
+ captureMessage(
1715
+ "line",
1716
+ userId,
1717
+ { type: "flex", altText: message.altText, contents: message.contents },
1697
1718
  connectionId
1698
1719
  );
1699
1720
  }
package/dist/setup.mjs CHANGED
@@ -9,6 +9,7 @@ function globalSingleton(key, create) {
9
9
  // src/state.ts
10
10
  var state = globalSingleton("state", () => ({
11
11
  db: null,
12
+ lastHarnessDataStoreManifest: null,
12
13
  currentUser: null,
13
14
  sessions: /* @__PURE__ */ new Map(),
14
15
  refreshSessions: /* @__PURE__ */ new Map(),
@@ -57,6 +58,7 @@ function requireDb() {
57
58
  var TEST_DOMAIN_SUFFIX = ".stardeck.test";
58
59
  var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
59
60
  var DATA_STORE_TEST_HOST = "db.stardeck.test";
61
+ var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
60
62
  var STORAGE_TEST_URL = "https://storage.stardeck.test";
61
63
  var STORAGE_TEST_HOST = "storage.stardeck.test";
62
64
  var TEST_ENV_DEFAULTS = {
@@ -65,7 +67,6 @@ var TEST_ENV_DEFAULTS = {
65
67
  ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
66
68
  PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
67
69
  DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
68
- DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`,
69
70
  STORAGE_URL: STORAGE_TEST_URL
70
71
  };
71
72
 
@@ -1597,6 +1598,9 @@ function isValidLineImageUrl(url) {
1597
1598
  return false;
1598
1599
  }
1599
1600
  }
1601
+ function isObject(value) {
1602
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1603
+ }
1600
1604
  function captureMessage(channel, recipient, body, connectionId) {
1601
1605
  const message = {
1602
1606
  channel,
@@ -1608,7 +1612,8 @@ function captureMessage(channel, recipient, body, connectionId) {
1608
1612
  messagingType: body.messagingType ? String(body.messagingType) : void 0,
1609
1613
  tag: body.tag ? String(body.tag) : void 0,
1610
1614
  originalContentUrl: body.originalContentUrl ? String(body.originalContentUrl) : void 0,
1611
- previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0
1615
+ previewImageUrl: body.previewImageUrl ? String(body.previewImageUrl) : void 0,
1616
+ ...body.type === "flex" && isObject(body.contents) ? { type: "flex", altText: String(body.altText), contents: body.contents } : {}
1612
1617
  },
1613
1618
  connectionId,
1614
1619
  sentAt: /* @__PURE__ */ new Date()
@@ -1646,8 +1651,8 @@ async function handleMessagingRequest(request, channel, subPath) {
1646
1651
  const userId = String(body.userId ?? "");
1647
1652
  const message = body.message;
1648
1653
  if (!userId) return failure("userId is required");
1649
- if (!message || message.type !== "text" && message.type !== "image") {
1650
- return failure("message must be { type: 'text' | 'image', ... }");
1654
+ if (!message || message.type !== "text" && message.type !== "image" && message.type !== "flex") {
1655
+ return failure("message must be { type: 'text' | 'image' | 'flex', ... }");
1651
1656
  }
1652
1657
  const connectionId = body.connectionId ? String(body.connectionId) : void 0;
1653
1658
  if (message.type === "text") {
@@ -1658,7 +1663,7 @@ async function handleMessagingRequest(request, channel, subPath) {
1658
1663
  return failure("message text exceeds maximum length");
1659
1664
  }
1660
1665
  captureMessage("line", userId, { text: message.text }, connectionId);
1661
- } else {
1666
+ } else if (message.type === "image") {
1662
1667
  const originalContentUrl = message.originalContentUrl;
1663
1668
  if (!originalContentUrl || !isValidLineImageUrl(originalContentUrl)) {
1664
1669
  return failure("originalContentUrl must be a valid HTTPS URL (max 2000 chars)");
@@ -1669,7 +1674,23 @@ async function handleMessagingRequest(request, channel, subPath) {
1669
1674
  captureMessage(
1670
1675
  "line",
1671
1676
  userId,
1672
- { originalContentUrl, previewImageUrl: message.previewImageUrl ?? originalContentUrl },
1677
+ {
1678
+ originalContentUrl,
1679
+ previewImageUrl: message.previewImageUrl ?? originalContentUrl
1680
+ },
1681
+ connectionId
1682
+ );
1683
+ } else {
1684
+ if (typeof message.altText !== "string" || !message.altText || message.altText.length > 400) {
1685
+ return failure("altText must be between 1 and 400 characters");
1686
+ }
1687
+ if (!isObject(message.contents) || message.contents.type !== "bubble" && message.contents.type !== "carousel") {
1688
+ return failure("contents.type must be 'bubble' or 'carousel'");
1689
+ }
1690
+ captureMessage(
1691
+ "line",
1692
+ userId,
1693
+ { type: "flex", altText: message.altText, contents: message.contents },
1673
1694
  connectionId
1674
1695
  );
1675
1696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/testing",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
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",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@stardeck-customer-apps/edge-sdk": ">=1.4.0",
69
- "@stardeck-customer-apps/integrations-sdk": ">=1.6.0",
69
+ "@stardeck-customer-apps/integrations-sdk": ">=1.13.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",