@cosmicdrift/kumiko-renderer 0.257.0 → 0.258.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.257.0",
3
+ "version": "0.258.1",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.257.0",
19
- "@cosmicdrift/kumiko-headless": "0.257.0",
18
+ "@cosmicdrift/kumiko-framework": "0.258.1",
19
+ "@cosmicdrift/kumiko-headless": "0.258.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.257.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.258.1"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -5,7 +5,11 @@ type FieldDef = {
5
5
  type?: string;
6
6
  default?: unknown;
7
7
  sensitive?: boolean;
8
+ format?: string;
8
9
  options?: readonly string[];
10
+ multiple?: boolean;
11
+ schema?: Record<string, { type?: string; options?: readonly string[] }>;
12
+ maxItems?: number;
9
13
  };
10
14
 
11
15
  describe("mergeSearchParamsIntoInitial", () => {
@@ -39,6 +43,14 @@ describe("mergeSearchParamsIntoInitial", () => {
39
43
  expect(result["password"]).toBe("");
40
44
  });
41
45
 
46
+ test("password-format field is skipped even when a matching searchParam exists (fw#2548)", () => {
47
+ const fields: Record<string, FieldDef> = {
48
+ apiToken: { type: "text", format: "password", default: "unset" },
49
+ };
50
+ const result = mergeSearchParamsIntoInitial(fields, { apiToken: "kpat_leak" });
51
+ expect(result["apiToken"]).toBe("unset");
52
+ });
53
+
42
54
  test("field with no matching searchParam keeps its buildInitialValues default", () => {
43
55
  const fields: Record<string, FieldDef> = { total: { type: "number", default: 100 } };
44
56
  const result = mergeSearchParamsIntoInitial(fields, {});
@@ -196,4 +208,142 @@ describe("mergeSearchParamsIntoInitial", () => {
196
208
  warnSpy.mockRestore();
197
209
  }
198
210
  });
211
+
212
+ describe("embeddedList prefill (fw#2764)", () => {
213
+ const linesField = (extra: Partial<FieldDef> = {}): Record<string, FieldDef> => ({
214
+ lines: {
215
+ type: "embedded",
216
+ multiple: true,
217
+ schema: {
218
+ accountId: { type: "text" },
219
+ amount: { type: "money" },
220
+ qty: { type: "number" },
221
+ posted: { type: "boolean" },
222
+ kind: { type: "select", options: ["debit", "credit"] },
223
+ },
224
+ ...extra,
225
+ },
226
+ });
227
+
228
+ test("a JSON row list from params.map arrives complete in the field", () => {
229
+ const rows = [
230
+ { accountId: "bank", amount: 1299, qty: 2, posted: true, kind: "debit" },
231
+ { accountId: "cash", amount: -1299, qty: 1, posted: false, kind: "credit" },
232
+ ];
233
+ const result = mergeSearchParamsIntoInitial(linesField(), { lines: JSON.stringify(rows) });
234
+ expect(result["lines"]).toEqual(rows);
235
+ });
236
+
237
+ test("money cells stay signed minor-unit integers", () => {
238
+ const result = mergeSearchParamsIntoInitial(linesField(), {
239
+ lines: JSON.stringify([{ accountId: "bank", amount: -4200 }]),
240
+ });
241
+ expect(result["lines"]).toEqual([{ accountId: "bank", amount: -4200 }]);
242
+ });
243
+
244
+ test("a fractional money cell rejects the whole prefill and warns", () => {
245
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
246
+ try {
247
+ const result = mergeSearchParamsIntoInitial(linesField(), {
248
+ lines: JSON.stringify([{ accountId: "bank", amount: 12.99 }]),
249
+ });
250
+ expect(result["lines"]).toEqual([]);
251
+ expect(warnSpy).toHaveBeenCalled();
252
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("lines");
253
+ } finally {
254
+ warnSpy.mockRestore();
255
+ }
256
+ });
257
+
258
+ test("a non-JSON param leaves the field empty and warns", () => {
259
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
260
+ try {
261
+ const result = mergeSearchParamsIntoInitial(linesField(), { lines: "not-json-at-all" });
262
+ expect(result["lines"]).toEqual([]);
263
+ expect(warnSpy).toHaveBeenCalled();
264
+ } finally {
265
+ warnSpy.mockRestore();
266
+ }
267
+ });
268
+
269
+ test("a JSON object instead of an array leaves the field empty and warns", () => {
270
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
271
+ try {
272
+ const result = mergeSearchParamsIntoInitial(linesField(), {
273
+ lines: JSON.stringify({ accountId: "bank" }),
274
+ });
275
+ expect(result["lines"]).toEqual([]);
276
+ expect(warnSpy).toHaveBeenCalled();
277
+ } finally {
278
+ warnSpy.mockRestore();
279
+ }
280
+ });
281
+
282
+ test("a row that is not an object leaves the field empty and warns", () => {
283
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
284
+ try {
285
+ const result = mergeSearchParamsIntoInitial(linesField(), {
286
+ lines: JSON.stringify([1, 2]),
287
+ });
288
+ expect(result["lines"]).toEqual([]);
289
+ expect(warnSpy).toHaveBeenCalled();
290
+ } finally {
291
+ warnSpy.mockRestore();
292
+ }
293
+ });
294
+
295
+ test("a select cell outside the declared options rejects the prefill and warns", () => {
296
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
297
+ try {
298
+ const result = mergeSearchParamsIntoInitial(linesField(), {
299
+ lines: JSON.stringify([{ accountId: "bank", kind: "sudo" }]),
300
+ });
301
+ expect(result["lines"]).toEqual([]);
302
+ expect(warnSpy).toHaveBeenCalled();
303
+ } finally {
304
+ warnSpy.mockRestore();
305
+ }
306
+ });
307
+
308
+ test("undeclared row keys are dropped instead of reaching the form", () => {
309
+ const result = mergeSearchParamsIntoInitial(linesField(), {
310
+ lines: JSON.stringify([{ accountId: "bank", secretFlag: "yes" }]),
311
+ });
312
+ expect(result["lines"]).toEqual([{ accountId: "bank" }]);
313
+ });
314
+
315
+ test("a __proto__ key in a row pollutes nothing", () => {
316
+ const result = mergeSearchParamsIntoInitial(linesField(), {
317
+ lines: '[{"accountId":"bank","__proto__":{"polluted":true}}]',
318
+ });
319
+ expect(result["lines"]).toEqual([{ accountId: "bank" }]);
320
+ expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
321
+ expect(Object.getPrototypeOf((result["lines"] as unknown[])[0])).toBe(Object.prototype);
322
+ });
323
+
324
+ test("more rows than maxItems leaves the field empty and warns", () => {
325
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
326
+ try {
327
+ const result = mergeSearchParamsIntoInitial(linesField({ maxItems: 2 }), {
328
+ lines: JSON.stringify([{ accountId: "a" }, { accountId: "b" }, { accountId: "c" }]),
329
+ });
330
+ expect(result["lines"]).toEqual([]);
331
+ expect(warnSpy).toHaveBeenCalled();
332
+ } finally {
333
+ warnSpy.mockRestore();
334
+ }
335
+ });
336
+
337
+ test("an embedded list without a matching param defaults to an empty array", () => {
338
+ const result = mergeSearchParamsIntoInitial(linesField(), {});
339
+ expect(result["lines"]).toEqual([]);
340
+ });
341
+
342
+ test("a sensitive embedded list ignores the param", () => {
343
+ const result = mergeSearchParamsIntoInitial(linesField({ sensitive: true }), {
344
+ lines: JSON.stringify([{ accountId: "bank" }]),
345
+ });
346
+ expect(result["lines"]).toEqual([]);
347
+ });
348
+ });
199
349
  });
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "../action-form-shim";
2
+ import {
3
+ synthesizeActionFormEntity,
4
+ synthesizeActionFormScreen,
5
+ synthesizeSecretMintConfirmScreen,
6
+ } from "../action-form-shim";
3
7
 
4
8
  describe("synthesizeActionFormEntity", () => {
5
9
  test("wraps inline fields as minimal EntityDefinition", () => {
@@ -45,3 +49,47 @@ describe("synthesizeActionFormScreen", () => {
45
49
  expect("description" in withoutDescription).toBe(false);
46
50
  });
47
51
  });
52
+
53
+ describe("synthesizeSecretMintConfirmScreen (fw#2838)", () => {
54
+ test("maps a secretMint's confirm step to the entityEdit shape RenderEdit expects, with a distinct id", () => {
55
+ const screen = synthesizeSecretMintConfirmScreen(
56
+ {
57
+ id: "mint-token",
58
+ type: "secretMint",
59
+ handler: "shop:write:token:mint",
60
+ fields: { label: { type: "text" } },
61
+ layout: { sections: [{ title: "Mint", fields: ["label"] }] },
62
+ reveal: { fields: [{ field: "token", label: "Token" }] },
63
+ },
64
+ {
65
+ handler: "shop:write:token:confirm",
66
+ fields: { code: { type: "text" } },
67
+ layout: { sections: [{ title: "Confirm", fields: ["code"] }] },
68
+ },
69
+ );
70
+ expect(screen.id).toBe("mint-token:confirm");
71
+ expect(screen.type).toBe("entityEdit");
72
+ expect(screen.entity).toBe("__action-form__");
73
+ expect(screen.layout).toEqual({ sections: [{ title: "Confirm", fields: ["code"] }] });
74
+ });
75
+
76
+ test("carries the mint screen's access rule through to the confirm form", () => {
77
+ const screen = synthesizeSecretMintConfirmScreen(
78
+ {
79
+ id: "mint-token",
80
+ type: "secretMint",
81
+ handler: "shop:write:token:mint",
82
+ fields: { label: { type: "text" } },
83
+ layout: { sections: [{ title: "Mint", fields: ["label"] }] },
84
+ reveal: { fields: [{ field: "token", label: "Token" }] },
85
+ access: { roles: ["admin"] },
86
+ },
87
+ {
88
+ handler: "shop:write:token:confirm",
89
+ fields: { code: { type: "text" } },
90
+ layout: { sections: [{ title: "Confirm", fields: ["code"] }] },
91
+ },
92
+ );
93
+ expect(screen.access).toEqual({ roles: ["admin"] });
94
+ });
95
+ });
@@ -0,0 +1,396 @@
1
+ // fw#2548: secretMint mints a one-time secret from a write-handler's own
2
+ // success payload. This renders the real path (KumikoScreen → SecretMintBody
3
+ // → RenderEdit) under a stub dispatcher, proving: (1) the secret is absent
4
+ // before submit, (2) only the whitelisted reveal.fields survive the submit —
5
+ // a stray "id" in the payload never leaks, (3) confirming clears the reveal
6
+ // from the DOM, (4) no query call ever runs that could reload the secret.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type {
10
+ SecretMintScreenDefinition,
11
+ TextFieldDef,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
14
+ import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
15
+ import type { ComponentType, ReactNode } from "react";
16
+ import { DispatcherProvider } from "../../context/dispatcher-context";
17
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
18
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
19
+ import {
20
+ type BannerProps,
21
+ type CorePrimitives,
22
+ PrimitivesProvider,
23
+ type SecretRevealProps,
24
+ type SectionProps,
25
+ type TextProps,
26
+ } from "../../primitives";
27
+ import type { FeatureSchema } from "../feature-schema";
28
+ import { KumikoScreen } from "../kumiko-screen";
29
+ import { NavProvider, type NavTarget } from "../nav";
30
+
31
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
32
+ const noop = () => null;
33
+
34
+ const testButton: ComponentType<{
35
+ children?: ReactNode;
36
+ onClick?: () => void;
37
+ testId?: string;
38
+ type?: "button" | "submit";
39
+ disabled?: boolean;
40
+ }> = ({ children, onClick, testId, type, disabled }) => (
41
+ <button type={type ?? "button"} data-testid={testId} onClick={onClick} disabled={disabled}>
42
+ {children}
43
+ </button>
44
+ );
45
+
46
+ const testForm: ComponentType<{
47
+ children?: ReactNode;
48
+ actions?: ReactNode;
49
+ secondaryActions?: ReactNode;
50
+ onSubmit?: () => void;
51
+ }> = ({ children, actions, secondaryActions, onSubmit }) => (
52
+ <form
53
+ onSubmit={(e) => {
54
+ e.preventDefault();
55
+ onSubmit?.();
56
+ }}
57
+ >
58
+ {children}
59
+ {secondaryActions}
60
+ {actions}
61
+ </form>
62
+ );
63
+
64
+ const testInput: ComponentType<{
65
+ name?: string;
66
+ value?: unknown;
67
+ onChange?: (v: unknown) => void;
68
+ }> = ({ name = "field", value, onChange }) => (
69
+ <input
70
+ aria-label={name}
71
+ data-testid={`input-${name}`}
72
+ value={typeof value === "string" ? value : ""}
73
+ onChange={(e) => onChange?.(e.target.value)}
74
+ />
75
+ );
76
+
77
+ const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
78
+ <div data-testid={testId}>{children}</div>
79
+ );
80
+ const testBanner: ComponentType<BannerProps> = ({ children, testId }) => (
81
+ <div data-testid={testId}>{children}</div>
82
+ );
83
+ const testText: ComponentType<TextProps> = ({ children, testId }) => (
84
+ <span data-testid={testId}>{children}</span>
85
+ );
86
+ // Renders every value's raw text plus a `data-qr` marker — lets tests assert
87
+ // both DOM-leak absence (no query needed to find rendered text) and that
88
+ // display: "qr" reaches the primitive as `qr: true`.
89
+ const testSecretReveal: ComponentType<SecretRevealProps> = ({ values, testId }) => (
90
+ <div data-testid={testId}>
91
+ {values.map((v) => (
92
+ <div key={v.label} data-testid={`secret-value-${v.label}`} data-qr={String(v.qr === true)}>
93
+ {v.value}
94
+ </div>
95
+ ))}
96
+ </div>
97
+ );
98
+
99
+ const testPrimitives: CorePrimitives = {
100
+ Button: testButton,
101
+ Banner: testBanner,
102
+ Field: passChildren,
103
+ Input: testInput,
104
+ DataTable: noop,
105
+ Form: testForm,
106
+ Section: testSection,
107
+ Card: passChildren,
108
+ Grid: passChildren,
109
+ GridCell: passChildren,
110
+ Text: testText,
111
+ Heading: passChildren,
112
+ Dialog: noop,
113
+ Modal: noop,
114
+ Lightbox: noop,
115
+ ConfigSourceBadge: noop,
116
+ ConfigCascadeView: noop,
117
+ Link: noop,
118
+ SecretReveal: testSecretReveal,
119
+ } as unknown as CorePrimitives;
120
+
121
+ const mintScreen: SecretMintScreenDefinition = {
122
+ id: "mint-token",
123
+ type: "secretMint",
124
+ handler: "shop:write:token:mint",
125
+ fields: { label: { type: "text" } as TextFieldDef },
126
+ layout: { sections: [{ title: "Mint", fields: ["label"] }] },
127
+ reveal: { fields: [{ field: "token", label: "Token" }] },
128
+ };
129
+
130
+ function buildSchema(screen: SecretMintScreenDefinition): FeatureSchema {
131
+ return {
132
+ featureName: "shop",
133
+ entities: {},
134
+ screens: [screen],
135
+ } as FeatureSchema;
136
+ }
137
+
138
+ function stubDispatcher(writeData: unknown): {
139
+ dispatcher: Dispatcher;
140
+ queryCalls: unknown[];
141
+ } {
142
+ const queryCalls: unknown[] = [];
143
+ const dispatcher: Dispatcher = {
144
+ write: (async () => ({ isSuccess: true, data: writeData })) as unknown as Dispatcher["write"],
145
+ query: (async (type: string, payload: unknown) => {
146
+ queryCalls.push({ type, payload });
147
+ return { isSuccess: true, data: {} };
148
+ }) as unknown as Dispatcher["query"],
149
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
150
+ statusStore: {
151
+ getState: () => "online",
152
+ subscribe: () => () => {},
153
+ } as unknown as Dispatcher["statusStore"],
154
+ async *stream() {},
155
+ pendingWrites: () => [],
156
+ pendingFiles: () => [],
157
+ };
158
+ return { dispatcher, queryCalls };
159
+ }
160
+
161
+ // Keyed stub — one write-response per handler QN, and every write call
162
+ // recorded with its full payload — for scenarios with more than one
163
+ // write-handler in play (mint + confirm).
164
+ function stubMultiWriteDispatcher(responses: Readonly<Record<string, unknown>>): {
165
+ dispatcher: Dispatcher;
166
+ writeCalls: Array<{ readonly command: string; readonly payload: unknown }>;
167
+ } {
168
+ const writeCalls: Array<{ readonly command: string; readonly payload: unknown }> = [];
169
+ const dispatcher: Dispatcher = {
170
+ write: (async (command: string, payload: unknown) => {
171
+ writeCalls.push({ command, payload });
172
+ return { isSuccess: true, data: responses[command] ?? {} };
173
+ }) as unknown as Dispatcher["write"],
174
+ query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
175
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
176
+ statusStore: {
177
+ getState: () => "online",
178
+ subscribe: () => () => {},
179
+ } as unknown as Dispatcher["statusStore"],
180
+ async *stream() {},
181
+ pendingWrites: () => [],
182
+ pendingFiles: () => [],
183
+ };
184
+ return { dispatcher, writeCalls };
185
+ }
186
+
187
+ function renderMintScreen(
188
+ dispatcher: Dispatcher,
189
+ screen: SecretMintScreenDefinition = mintScreen,
190
+ navigate: (target: NavTarget) => void = () => {},
191
+ ) {
192
+ const qn = `shop:screen:${screen.id}`;
193
+ return render(
194
+ <LocaleProvider
195
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
196
+ fallbackBundles={[kumikoDefaultTranslations]}
197
+ >
198
+ <DispatcherProvider dispatcher={dispatcher}>
199
+ <NavProvider
200
+ value={{
201
+ route: { screenId: qn },
202
+ navigate,
203
+ replace: () => {},
204
+ hrefFor: () => "",
205
+ searchParams: {},
206
+ setSearchParams: () => {},
207
+ }}
208
+ >
209
+ <PrimitivesProvider value={testPrimitives}>
210
+ <KumikoScreen schema={buildSchema(screen)} qn={qn} />
211
+ </PrimitivesProvider>
212
+ </NavProvider>
213
+ </DispatcherProvider>
214
+ </LocaleProvider>,
215
+ );
216
+ }
217
+
218
+ describe("SecretMintBody (fw#2548)", () => {
219
+ test("before submit: the secret is not in the DOM", () => {
220
+ const { dispatcher } = stubDispatcher({ token: "kpat_secret", id: "x" });
221
+ renderMintScreen(dispatcher);
222
+ expect(rtlScreen.queryByText("kpat_secret")).toBeNull();
223
+ });
224
+
225
+ test("after a successful submit: only the whitelisted reveal field appears — not the payload's other fields", async () => {
226
+ const { dispatcher } = stubDispatcher({ token: "kpat_secret", id: "x" });
227
+ renderMintScreen(dispatcher);
228
+
229
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
230
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
231
+
232
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
233
+ expect(rtlScreen.queryByText("x")).toBeNull();
234
+ });
235
+
236
+ test("confirming the reveal clears the secret from the DOM", async () => {
237
+ const { dispatcher } = stubDispatcher({ token: "kpat_secret", id: "x" });
238
+ renderMintScreen(dispatcher);
239
+
240
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
241
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
242
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
243
+
244
+ fireEvent.click(rtlScreen.getByTestId("kumiko-screen-secret-mint-confirm"));
245
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).toBeNull());
246
+ });
247
+
248
+ test("the secret is never fetched via a query — it only ever comes from the write result", async () => {
249
+ const { dispatcher, queryCalls } = stubDispatcher({ token: "kpat_secret", id: "x" });
250
+ renderMintScreen(dispatcher);
251
+
252
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
253
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
254
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
255
+
256
+ expect(queryCalls.length).toBe(0);
257
+ });
258
+
259
+ test("acknowledging without a confirm step and without a redirect shows the done banner", async () => {
260
+ const { dispatcher } = stubDispatcher({ token: "kpat_secret", id: "x" });
261
+ renderMintScreen(dispatcher);
262
+
263
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
264
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
265
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
266
+
267
+ fireEvent.click(rtlScreen.getByTestId("kumiko-screen-secret-mint-confirm"));
268
+ await waitFor(() =>
269
+ expect(rtlScreen.queryByTestId("kumiko-screen-secret-mint-done")).not.toBeNull(),
270
+ );
271
+ });
272
+
273
+ test("acknowledging with a redirect navigates instead of showing the done banner", async () => {
274
+ const { dispatcher } = stubDispatcher({ token: "kpat_secret", id: "x" });
275
+ const navigateCalls: NavTarget[] = [];
276
+ renderMintScreen(dispatcher, { ...mintScreen, redirect: "after-mint" }, (target) =>
277
+ navigateCalls.push(target),
278
+ );
279
+
280
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
281
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
282
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
283
+
284
+ fireEvent.click(rtlScreen.getByTestId("kumiko-screen-secret-mint-confirm"));
285
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
286
+ expect(navigateCalls[0]).toEqual({ screenId: "after-mint" });
287
+ expect(rtlScreen.queryByTestId("kumiko-screen-secret-mint-done")).toBeNull();
288
+ });
289
+
290
+ test("a reveal field with display 'qr' reaches the SecretReveal primitive with qr: true", async () => {
291
+ const qrScreen: SecretMintScreenDefinition = {
292
+ ...mintScreen,
293
+ reveal: { fields: [{ field: "token", label: "Token", display: "qr" }] },
294
+ };
295
+ const { dispatcher } = stubDispatcher({ token: "otpauth://totp/x?secret=ABC", id: "x" });
296
+ renderMintScreen(dispatcher, qrScreen);
297
+
298
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
299
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
300
+
301
+ await waitFor(() =>
302
+ expect(rtlScreen.getByTestId("secret-value-Token").getAttribute("data-qr")).toBe("true"),
303
+ );
304
+ });
305
+
306
+ test("an input-less secretMint (no fields) has an active submit button and dispatches the mint handler on click", async () => {
307
+ const inputLessScreen: SecretMintScreenDefinition = {
308
+ id: "mint-token",
309
+ type: "secretMint",
310
+ handler: "shop:write:token:mint",
311
+ fields: {},
312
+ layout: { sections: [] },
313
+ reveal: { fields: [{ field: "token", label: "Token" }] },
314
+ };
315
+ const { dispatcher, writeCalls } = stubMultiWriteDispatcher({
316
+ "shop:write:token:mint": { token: "kpat_secret" },
317
+ });
318
+ renderMintScreen(dispatcher, inputLessScreen);
319
+
320
+ const submit = rtlScreen.getByTestId("render-edit-submit") as HTMLButtonElement;
321
+ expect(submit.disabled).toBe(false);
322
+ fireEvent.click(submit);
323
+
324
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
325
+ expect(writeCalls.map((c) => c.command)).toEqual(["shop:write:token:mint"]);
326
+ });
327
+ });
328
+
329
+ describe("SecretMintBody confirm step (fw#2838)", () => {
330
+ const mintScreenWithConfirm: SecretMintScreenDefinition = {
331
+ ...mintScreen,
332
+ confirm: {
333
+ handler: "shop:write:token:confirm",
334
+ fields: { code: { type: "text" } as TextFieldDef },
335
+ layout: { sections: [{ title: "Confirm", fields: ["code"] }] },
336
+ carry: ["setupToken"],
337
+ },
338
+ };
339
+
340
+ test("renders the confirm form instead of the acknowledge button, and submits the confirm form's own values merged with the carried mint-payload field", async () => {
341
+ const { dispatcher, writeCalls } = stubMultiWriteDispatcher({
342
+ "shop:write:token:mint": { token: "kpat_secret", id: "x", setupToken: "stok_123" },
343
+ "shop:write:token:confirm": {},
344
+ });
345
+ renderMintScreen(dispatcher, mintScreenWithConfirm);
346
+
347
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
348
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
349
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
350
+
351
+ // No bare acknowledge button — the confirm form takes its place.
352
+ expect(rtlScreen.queryByTestId("kumiko-screen-secret-mint-confirm")).toBeNull();
353
+
354
+ fireEvent.change(rtlScreen.getByLabelText(/code/i), { target: { value: "123456" } });
355
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
356
+
357
+ await waitFor(() =>
358
+ expect(writeCalls.some((c) => c.command === "shop:write:token:confirm")).toBe(true),
359
+ );
360
+ const confirmCall = writeCalls.find((c) => c.command === "shop:write:token:confirm");
361
+ expect(confirmCall?.payload).toEqual({ code: "123456", setupToken: "stok_123" });
362
+ });
363
+
364
+ test("a carried field that is not in reveal.fields never appears in the DOM", async () => {
365
+ const { dispatcher } = stubMultiWriteDispatcher({
366
+ "shop:write:token:mint": { token: "kpat_secret", id: "x", setupToken: "stok_123" },
367
+ });
368
+ renderMintScreen(dispatcher, mintScreenWithConfirm);
369
+
370
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
371
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
372
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
373
+
374
+ expect(rtlScreen.queryByText("stok_123")).toBeNull();
375
+ });
376
+
377
+ test("without a redirect, a successful confirm shows the done banner", async () => {
378
+ const { dispatcher } = stubMultiWriteDispatcher({
379
+ "shop:write:token:mint": { token: "kpat_secret", id: "x", setupToken: "stok_123" },
380
+ "shop:write:token:confirm": {},
381
+ });
382
+ renderMintScreen(dispatcher, mintScreenWithConfirm);
383
+
384
+ fireEvent.change(rtlScreen.getByLabelText(/label/i), { target: { value: "My token" } });
385
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
386
+ await waitFor(() => expect(rtlScreen.queryByText("kpat_secret")).not.toBeNull());
387
+
388
+ fireEvent.change(rtlScreen.getByLabelText(/code/i), { target: { value: "123456" } });
389
+ fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
390
+
391
+ await waitFor(() =>
392
+ expect(rtlScreen.queryByTestId("kumiko-screen-secret-mint-done")).not.toBeNull(),
393
+ );
394
+ expect(rtlScreen.queryByText("kpat_secret")).toBeNull();
395
+ });
396
+ });