@civitai/blocks-react 0.8.0 → 0.10.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.
@@ -6,8 +6,9 @@
6
6
  * a block in a cross-origin iframe and answers its `postMessage` protocol:
7
7
  * mints a token, runs the lazy-consent round-trip, brokers the orchestrator
8
8
  * money path (estimate → submit → poll), opens the native Buzz-purchase and
9
- * resource-picker modals. Locally in a `vitest` test OR a starter's dev
10
- * harness — there is no host, so this plays one.
9
+ * resource-picker modals, and serves the App-Blocks KV datastore. Locally in
10
+ * a `vitest` test OR a starter's dev harness — there is no host, so this plays
11
+ * one.
11
12
  *
12
13
  * It is the portable core that the React `<Harness>` (a.k.a. `<MockHostProvider>`
13
14
  * in `../testing`) wraps. Every block app used to hand-roll ~250 lines of this;
@@ -22,11 +23,33 @@
22
23
  * `window.location.origin` (the React `<Harness>` is documented for that).
23
24
  * 3. Dispatches a configurable `BLOCK_INIT`, then answers the full protocol.
24
25
  *
25
- * NOT a real RS256 JWT, NO real Buzz, NO orchestrator — only the bridge
26
- * round-trips are exercised. Never import this from production code.
26
+ * SCENARIOS (Layer 1 of the local-dev DX): the {@link MockHostOptions.generation},
27
+ * {@link MockHostOptions.buzz}, and {@link MockHostOptions.storage} groups let a
28
+ * dev simulate REAL costs, slow gens, FAILURES, an insufficient-Buzz balance,
29
+ * and a working KV store with a quota — entirely synthetically, so the full
30
+ * money / error / storage UX is exercisable locally without spending a single
31
+ * Buzz or touching the network. The returned {@link MockHost} exposes
32
+ * `setScenario()` + a `buzz` handle so a harness UI can flip scenarios
33
+ * mid-session.
34
+ *
35
+ * PURE + SYNTHETIC: NOT a real RS256 JWT, NO real Buzz, NO orchestrator, and
36
+ * — asserted by the test suite — NO network (`fetch`/`XMLHttpRequest` are never
37
+ * called). Only the postMessage bridge round-trips are exercised. Never import
38
+ * this from production code.
27
39
  */
40
+ import { BrowsingLevel, SFW_LEVELS, } from '@civitai/app-sdk/blocks';
41
+ /** The full all-levels ceiling a `red` domain projects (mirrors the server). */
42
+ const ALL_LEVELS = BrowsingLevel.PG |
43
+ BrowsingLevel.PG13 |
44
+ BrowsingLevel.R |
45
+ BrowsingLevel.X |
46
+ BrowsingLevel.XXX;
28
47
  const DEV_TOKEN = 'dev.mockhost.mock.jwt.NOT.A.REAL.RS256';
29
48
  const BUDGETED_SCOPE = 'ai:write:budgeted';
49
+ /** v0 host storage ceilings (mirror civitai/civitai's APP_STORAGE limits). */
50
+ const DEFAULT_STORAGE_QUOTA_BYTES = 50 * 1024 * 1024; // 50 MB per app
51
+ const DEFAULT_STORAGE_VALUE_CAP_BYTES = 64 * 1024; // 64 KB per value
52
+ const DEFAULT_STORAGE_LIMIT_ROWS = 1_000_000;
30
53
  const DEFAULT_CHECKPOINT_PICK = {
31
54
  versionId: 691639,
32
55
  modelId: 618692,
@@ -44,9 +67,24 @@ const DEFAULT_LORA_PICK = {
44
67
  modelType: 'LORA',
45
68
  };
46
69
  const DEFAULT_VIEWER = { id: 2, username: 'dev-viewer', status: 'active' };
70
+ const INSUFFICIENT_BUZZ_ERROR = 'Insufficient Buzz to run this generation.';
71
+ const GENERIC_GEN_ERROR = 'Generation failed (simulated).';
72
+ /** Byte size of a JSON value as the mock store would persist it. */
73
+ function jsonByteSize(value) {
74
+ try {
75
+ return new TextEncoder().encode(JSON.stringify(value ?? null)).length;
76
+ }
77
+ catch {
78
+ return 0;
79
+ }
80
+ }
47
81
  /**
48
82
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
49
83
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
84
+ * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
85
+ * map onto the new scenario groups so a dev can flip insufficient-buzz /
86
+ * failures / latency without editing code.
87
+ *
50
88
  * Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
51
89
  * (URL wins — it's the interactive dev knob). No-op outside a browser.
52
90
  */
@@ -68,6 +106,14 @@ export function readMockHostUrlOptions(win = globalThis
68
106
  out.theme = 'light';
69
107
  else if (params.get('theme') === 'dark')
70
108
  out.theme = 'dark';
109
+ // ?domain=green|blue|red projects a color-domain (and its derived ceiling);
110
+ // ?maturity=sfw|mature sets the ceiling directly.
111
+ const domain = params.get('domain');
112
+ if (domain === 'green' || domain === 'blue' || domain === 'red')
113
+ out.domain = domain;
114
+ const maturity = params.get('maturity');
115
+ if (maturity === 'sfw' || maturity === 'mature')
116
+ out.maturity = maturity;
71
117
  // ?pick (LoRA) / ?pickCkpt (Checkpoint): 'cancel' → dismissed; 'pony' → an
72
118
  // incompatible Pony LoRA; any other value → the default curated pick.
73
119
  const pick = params.get('pick');
@@ -93,6 +139,56 @@ export function readMockHostUrlOptions(win = globalThis
93
139
  cannedPicks.Checkpoint = DEFAULT_CHECKPOINT_PICK;
94
140
  out.cannedPicks = cannedPicks;
95
141
  }
142
+ // --- Layer-1 scenario toggles ---
143
+ const generation = {};
144
+ const buzz = {};
145
+ const balance = params.get('balance');
146
+ if (balance !== null && balance.trim() !== '' && Number.isFinite(Number(balance))) {
147
+ buzz.balance = Number(balance);
148
+ }
149
+ if (params.get('insufficient') === '1' || params.get('insufficient') === 'true') {
150
+ buzz.insufficient = true;
151
+ }
152
+ const latency = params.get('latency');
153
+ if (latency !== null && latency.trim() !== '') {
154
+ // ?latency=2000 or ?latency=500-2000
155
+ const range = latency.split('-').map((s) => Number(s.trim()));
156
+ const lo = range[0] ?? NaN;
157
+ const hi = range[1] ?? NaN;
158
+ if (range.length === 2 && Number.isFinite(lo) && Number.isFinite(hi)) {
159
+ generation.latencyMs = [lo, hi];
160
+ }
161
+ else if (Number.isFinite(lo)) {
162
+ generation.latencyMs = lo;
163
+ }
164
+ }
165
+ const costPerGen = params.get('costPerGen') ?? params.get('cost');
166
+ if (costPerGen !== null && Number.isFinite(Number(costPerGen))) {
167
+ generation.costPerGen = Number(costPerGen);
168
+ }
169
+ const failNext = params.get('failNext');
170
+ if (failNext !== null && Number.isInteger(Number(failNext))) {
171
+ generation.failNext = Number(failNext);
172
+ }
173
+ const failRate = params.get('failRate');
174
+ if (failRate !== null && Number.isFinite(Number(failRate))) {
175
+ generation.failRate = Number(failRate);
176
+ }
177
+ if (Object.keys(generation).length > 0)
178
+ out.generation = generation;
179
+ if (Object.keys(buzz).length > 0)
180
+ out.buzz = buzz;
181
+ const seed = params.get('seed');
182
+ if (seed) {
183
+ try {
184
+ const parsed = JSON.parse(seed);
185
+ if (parsed && typeof parsed === 'object')
186
+ out.storage = { seed: parsed };
187
+ }
188
+ catch {
189
+ /* ignore malformed ?seed= */
190
+ }
191
+ }
96
192
  return out;
97
193
  }
98
194
  /**
@@ -102,9 +198,11 @@ export function readMockHostUrlOptions(win = globalThis
102
198
  * from a node/jsdom/happy-dom test OR a browser dev harness.
103
199
  *
104
200
  * @example
105
- * const host = createMockHost({ failMode: 'some', pollsUntilDone: 1 });
201
+ * const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
106
202
  * const uninstall = host.install();
107
203
  * // … drive the block / assertions …
204
+ * host.buzz.setBalance(0); // flip to insufficient mid-session
205
+ * host.setScenario({ generation: { failRate: 1 } });
108
206
  * uninstall();
109
207
  */
110
208
  export function createMockHost(options = {}) {
@@ -115,15 +213,62 @@ export function createMockHost(options = {}) {
115
213
  // Bind to a non-nullable local so the `install()` closure keeps the narrowing.
116
214
  const win = maybeWin;
117
215
  const viewer = options.viewer === undefined ? DEFAULT_VIEWER : options.viewer;
118
- const failMode = options.failMode ?? 'none';
119
- const pollsUntilDone = options.pollsUntilDone ?? 2;
120
- const cost = options.cost ?? 8;
121
216
  const buzzBudget = options.buzzBudget ?? 200;
122
217
  const theme = options.theme ?? 'dark';
123
- const cannedPicks = options.cannedPicks ?? { Checkpoint: DEFAULT_CHECKPOINT_PICK, LORA: DEFAULT_LORA_PICK };
124
218
  const blockInstanceId = options.blockInstanceId ?? 'page_mock';
125
219
  const blockId = options.blockId ?? 'mock-block';
126
220
  const appId = options.appId ?? 'app_dev';
221
+ // ---- LIVE, mutable scenario state (so setScenario / buzz.setBalance work) ----
222
+ // Legacy + scenario knobs are merged into one mutable record; `setScenario`
223
+ // rewrites these in place without re-installing.
224
+ let failMode = options.failMode ?? 'none';
225
+ let pollsUntilDone = options.pollsUntilDone ?? 2;
226
+ let gen = { ...(options.generation ?? {}) };
227
+ let buzz = { ...(options.buzz ?? {}) };
228
+ // Legacy `cost` feeds costPerGen unless the scenario set its own.
229
+ let legacyCost = options.cost ?? 8;
230
+ let cannedPicks = options.cannedPicks ?? { Checkpoint: DEFAULT_CHECKPOINT_PICK, LORA: DEFAULT_LORA_PICK };
231
+ // ---- Storage scenario (in-memory KV backend) ----
232
+ const storageScenario = { ...(options.storage ?? {}) };
233
+ const store = new Map();
234
+ const seedNow = new Date().toISOString();
235
+ for (const [k, v] of Object.entries(storageScenario.seed ?? {})) {
236
+ store.set(k, { value: v, updatedAt: seedNow });
237
+ }
238
+ let storageFailNext = storageScenario.failNext ?? 0;
239
+ const quotaBytes = storageScenario.quotaBytes ?? DEFAULT_STORAGE_QUOTA_BYTES;
240
+ const valueCapBytes = storageScenario.valueCapBytes ?? DEFAULT_STORAGE_VALUE_CAP_BYTES;
241
+ const limitRows = storageScenario.limitRows ?? DEFAULT_STORAGE_LIMIT_ROWS;
242
+ const usedBytes = () => {
243
+ let total = 0;
244
+ for (const [k, row] of store)
245
+ total += jsonByteSize(row.value) + k.length;
246
+ return total;
247
+ };
248
+ // Resolve a per-gen cost from the scenario (or legacy `cost`).
249
+ const costFor = (body) => {
250
+ const spec = gen.costPerGen ?? legacyCost;
251
+ return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
252
+ };
253
+ const latencyFor = () => {
254
+ const l = gen.latencyMs;
255
+ if (l === undefined)
256
+ return 0;
257
+ if (Array.isArray(l)) {
258
+ const [min, max] = l;
259
+ return Math.round(min + Math.random() * Math.max(0, max - min));
260
+ }
261
+ return l;
262
+ };
263
+ const imagesFor = (workflowId, body) => {
264
+ if (gen.images)
265
+ return typeof gen.images === 'function' ? gen.images(body) : gen.images;
266
+ if (gen.image)
267
+ return [typeof gen.image === 'function' ? gen.image(body) : gen.image];
268
+ return [
269
+ `https://placehold.co/512x512/1971c2/ffffff/png?text=${encodeURIComponent(workflowId.slice(-4))}`,
270
+ ];
271
+ };
127
272
  let installed = false;
128
273
  let teardown = () => { };
129
274
  function install() {
@@ -135,11 +280,19 @@ export function createMockHost(options = {}) {
135
280
  let consentGranted = !!options.consentGranted;
136
281
  let tokenSerial = 0;
137
282
  let submitCount = 0;
283
+ // body + cost remembered per workflow so the succeeded snapshot can echo them.
138
284
  const workflows = new Map();
139
285
  const timers = new Set();
140
286
  const dispatchToBlock = (data) => {
141
287
  win.dispatchEvent(new MessageEvent('message', { data, origin: parentOrigin }));
142
288
  };
289
+ const after = (ms, fn) => {
290
+ const t = setTimeout(() => {
291
+ timers.delete(t);
292
+ fn();
293
+ }, ms);
294
+ timers.add(t);
295
+ };
143
296
  const nextToken = () => {
144
297
  tokenSerial += 1;
145
298
  return {
@@ -149,14 +302,17 @@ export function createMockHost(options = {}) {
149
302
  ...(consentGranted ? { buzzBudget } : {}),
150
303
  };
151
304
  };
152
- const succeededSnapshot = (workflowId) => ({
153
- workflowId,
154
- status: 'succeeded',
155
- cost: { total: cost },
156
- imageUrls: [
157
- `https://placehold.co/512x512/1971c2/ffffff/png?text=${encodeURIComponent(workflowId.slice(-4))}`,
158
- ],
159
- });
305
+ const succeededSnapshot = (workflowId) => {
306
+ const wf = workflows.get(workflowId);
307
+ const cost = wf?.cost ?? legacyCost;
308
+ const body = wf?.body ?? {};
309
+ return {
310
+ workflowId,
311
+ status: 'succeeded',
312
+ cost: { total: cost },
313
+ imageUrls: imagesFor(workflowId, body),
314
+ };
315
+ };
160
316
  const parentMock = {
161
317
  postMessage: (msg) => {
162
318
  if (typeof msg !== 'object' ||
@@ -179,30 +335,56 @@ export function createMockHost(options = {}) {
179
335
  // host-initiated TOKEN_REFRESH carrying it (the App's auto-resume
180
336
  // depends on seeing the new scope on its token).
181
337
  consentGranted = true;
182
- const t = setTimeout(() => {
338
+ after(0, () => {
183
339
  dispatchToBlock({ type: 'TOKEN_REFRESH', payload: { token: nextToken() } });
184
- }, 0);
185
- timers.add(t);
340
+ });
186
341
  return;
187
342
  }
188
343
  case 'REQUEST_SIGN_IN':
189
344
  // The real host opens its login UI; nothing to reply.
190
345
  return;
191
- case 'ESTIMATE_WORKFLOW':
346
+ case 'ESTIMATE_WORKFLOW': {
347
+ const body = typed.payload?.body ?? {};
192
348
  dispatchToBlock({
193
349
  type: 'ESTIMATE_RESULT',
194
350
  payload: {
195
351
  requestId,
196
- snapshot: { workflowId: 'wf_estimate', status: 'pending', cost: { total: cost } },
352
+ snapshot: {
353
+ workflowId: 'wf_estimate',
354
+ status: 'pending',
355
+ cost: { total: costFor(body) },
356
+ },
197
357
  },
198
358
  });
199
359
  return;
360
+ }
200
361
  case 'SUBMIT_WORKFLOW': {
201
362
  submitCount += 1;
202
- const failThis = failMode === 'all' ||
363
+ const body = typed.payload?.body ?? {};
364
+ const cost = costFor(body);
365
+ // Insufficient-Buzz path: legacy failMode, the buzz scenario's
366
+ // force flag, OR a simulated balance that can't cover this gen.
367
+ const balanceSimulated = typeof buzz.balance === 'number';
368
+ const insufficient = failMode === 'all' ||
203
369
  failMode === 'insufficient' ||
204
- (failMode === 'some' && submitCount % 3 === 0);
205
- if (failThis) {
370
+ buzz.insufficient === true ||
371
+ (balanceSimulated && buzz.balance < cost);
372
+ // Generic generation failure: failNext countdown, failRate dice, or
373
+ // the legacy 'some' (~1 in 3) mode.
374
+ let genericFail = false;
375
+ if (!insufficient) {
376
+ if ((gen.failNext ?? 0) > 0) {
377
+ gen.failNext = gen.failNext - 1;
378
+ genericFail = true;
379
+ }
380
+ else if (typeof gen.failRate === 'number' && Math.random() < gen.failRate) {
381
+ genericFail = true;
382
+ }
383
+ else if (failMode === 'some' && submitCount % 3 === 0) {
384
+ genericFail = true;
385
+ }
386
+ }
387
+ if (insufficient) {
206
388
  dispatchToBlock({
207
389
  type: 'WORKFLOW_SUBMITTED',
208
390
  payload: {
@@ -210,14 +392,31 @@ export function createMockHost(options = {}) {
210
392
  snapshot: {
211
393
  workflowId: `wf_fail_${submitCount}`,
212
394
  status: 'failed',
213
- error: 'Insufficient Buzz to run this generation.',
395
+ error: INSUFFICIENT_BUZZ_ERROR,
214
396
  },
215
397
  },
216
398
  });
217
399
  return;
218
400
  }
401
+ if (genericFail) {
402
+ dispatchToBlock({
403
+ type: 'WORKFLOW_SUBMITTED',
404
+ payload: {
405
+ requestId,
406
+ snapshot: {
407
+ workflowId: `wf_fail_${submitCount}`,
408
+ status: 'failed',
409
+ error: GENERIC_GEN_ERROR,
410
+ },
411
+ },
412
+ });
413
+ return;
414
+ }
415
+ // Success path: debit the simulated balance + remember body/cost.
416
+ if (balanceSimulated)
417
+ buzz.balance = buzz.balance - cost;
219
418
  const workflowId = `wf_${submitCount}_${Date.now()}`;
220
- workflows.set(workflowId, { polls: 0 });
419
+ workflows.set(workflowId, { polls: 0, cost, body });
221
420
  dispatchToBlock({
222
421
  type: 'WORKFLOW_SUBMITTED',
223
422
  payload: { requestId, snapshot: { workflowId, status: 'pending' } },
@@ -230,10 +429,28 @@ export function createMockHost(options = {}) {
230
429
  const polls = (wf?.polls ?? 0) + 1;
231
430
  if (wf)
232
431
  wf.polls = polls;
233
- const snapshot = polls >= pollsUntilDone
234
- ? succeededSnapshot(workflowId)
235
- : { workflowId, status: 'processing' };
236
- dispatchToBlock({ type: 'WORKFLOW_STATUS', payload: { requestId, snapshot } });
432
+ if (polls >= pollsUntilDone) {
433
+ // Apply synthetic latency on the terminal (succeeded) poll only.
434
+ const delay = latencyFor();
435
+ if (delay > 0) {
436
+ after(delay, () => dispatchToBlock({
437
+ type: 'WORKFLOW_STATUS',
438
+ payload: { requestId, snapshot: succeededSnapshot(workflowId) },
439
+ }));
440
+ }
441
+ else {
442
+ dispatchToBlock({
443
+ type: 'WORKFLOW_STATUS',
444
+ payload: { requestId, snapshot: succeededSnapshot(workflowId) },
445
+ });
446
+ }
447
+ }
448
+ else {
449
+ dispatchToBlock({
450
+ type: 'WORKFLOW_STATUS',
451
+ payload: { requestId, snapshot: { workflowId, status: 'processing' } },
452
+ });
453
+ }
237
454
  return;
238
455
  }
239
456
  case 'CANCEL_WORKFLOW': {
@@ -245,12 +462,18 @@ export function createMockHost(options = {}) {
245
462
  });
246
463
  return;
247
464
  }
248
- case 'OPEN_BUZZ_PURCHASE':
465
+ case 'OPEN_BUZZ_PURCHASE': {
466
+ // Refill the simulated balance so the post-top-up retry succeeds.
467
+ const newBalance = typeof buzz.balance === 'number' ? buzz.balance + 1000 : 1000;
468
+ if (typeof buzz.balance === 'number')
469
+ buzz.balance = newBalance;
470
+ buzz.insufficient = false;
249
471
  dispatchToBlock({
250
472
  type: 'BUZZ_PURCHASE_RESULT',
251
- payload: { requestId, purchased: true, newBalance: 1000 },
473
+ payload: { requestId, purchased: true, newBalance },
252
474
  });
253
475
  return;
476
+ }
254
477
  case 'OPEN_CHECKPOINT_PICKER': {
255
478
  const selected = cannedPicks.Checkpoint;
256
479
  dispatchToBlock({
@@ -281,6 +504,111 @@ export function createMockHost(options = {}) {
281
504
  });
282
505
  return;
283
506
  }
507
+ // ---- App Blocks KV datastore (W4) — in-memory backend ----
508
+ case 'APP_STORAGE_GET': {
509
+ const key = typed.payload?.key ?? '';
510
+ const row = store.get(key);
511
+ dispatchToBlock({
512
+ type: 'APP_STORAGE_GET_RESULT',
513
+ payload: { requestId, value: row ? row.value : null },
514
+ });
515
+ return;
516
+ }
517
+ case 'APP_STORAGE_SET': {
518
+ const key = typed.payload?.key ?? '';
519
+ const value = typed.payload?.value;
520
+ if (storageFailNext > 0) {
521
+ storageFailNext -= 1;
522
+ dispatchToBlock({
523
+ type: 'APP_STORAGE_SET_RESULT',
524
+ payload: { requestId, ok: false, error: 'STORAGE_UNAVAILABLE' },
525
+ });
526
+ return;
527
+ }
528
+ const sizeBytes = jsonByteSize(value);
529
+ if (sizeBytes > valueCapBytes) {
530
+ dispatchToBlock({
531
+ type: 'APP_STORAGE_SET_RESULT',
532
+ payload: { requestId, ok: false, error: 'PAYLOAD_TOO_LARGE' },
533
+ });
534
+ return;
535
+ }
536
+ // Quota check: projected usage after this upsert.
537
+ const existing = store.get(key);
538
+ const existingBytes = existing ? jsonByteSize(existing.value) + key.length : 0;
539
+ const projected = usedBytes() - existingBytes + sizeBytes + key.length;
540
+ if (projected > quotaBytes) {
541
+ dispatchToBlock({
542
+ type: 'APP_STORAGE_SET_RESULT',
543
+ payload: { requestId, ok: false, error: 'PAYLOAD_TOO_LARGE' },
544
+ });
545
+ return;
546
+ }
547
+ store.set(key, { value, updatedAt: new Date().toISOString() });
548
+ dispatchToBlock({
549
+ type: 'APP_STORAGE_SET_RESULT',
550
+ payload: { requestId, ok: true, sizeBytes },
551
+ });
552
+ return;
553
+ }
554
+ case 'APP_STORAGE_DELETE': {
555
+ const key = typed.payload?.key ?? '';
556
+ if (storageFailNext > 0) {
557
+ storageFailNext -= 1;
558
+ dispatchToBlock({
559
+ type: 'APP_STORAGE_DELETE_RESULT',
560
+ payload: { requestId, ok: false, deleted: false, error: 'STORAGE_UNAVAILABLE' },
561
+ });
562
+ return;
563
+ }
564
+ const had = store.delete(key);
565
+ dispatchToBlock({
566
+ type: 'APP_STORAGE_DELETE_RESULT',
567
+ payload: { requestId, ok: true, deleted: had },
568
+ });
569
+ return;
570
+ }
571
+ case 'APP_STORAGE_LIST': {
572
+ const prefix = typed.payload?.prefix ?? '';
573
+ const limit = typed.payload?.limit ?? 100;
574
+ const cursor = typed.payload?.cursor;
575
+ // Cursor = base64 of the last returned key (matches the hook's
576
+ // documented `nextCursor` contract).
577
+ const afterKey = cursor ? safeAtob(cursor) : undefined;
578
+ const allKeys = [...store.entries()]
579
+ .filter(([k]) => k.startsWith(prefix))
580
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
581
+ const startIdx = afterKey
582
+ ? allKeys.findIndex(([k]) => k > afterKey)
583
+ : 0;
584
+ const slice = (startIdx < 0 ? [] : allKeys.slice(startIdx)).slice(0, limit);
585
+ const keys = slice.map(([key, row]) => ({ key, updatedAt: row.updatedAt }));
586
+ const last = slice[slice.length - 1]?.[0];
587
+ const hasMore = last !== undefined &&
588
+ allKeys.findIndex(([k]) => k === last) < allKeys.length - 1;
589
+ dispatchToBlock({
590
+ type: 'APP_STORAGE_LIST_RESULT',
591
+ payload: {
592
+ requestId,
593
+ keys,
594
+ ...(hasMore && last ? { nextCursor: safeBtoa(last) } : {}),
595
+ },
596
+ });
597
+ return;
598
+ }
599
+ case 'APP_STORAGE_QUOTA': {
600
+ dispatchToBlock({
601
+ type: 'APP_STORAGE_QUOTA_RESULT',
602
+ payload: {
603
+ requestId,
604
+ usedBytes: usedBytes(),
605
+ rowCount: store.size,
606
+ limitBytes: quotaBytes,
607
+ limitRows,
608
+ },
609
+ });
610
+ return;
611
+ }
284
612
  default:
285
613
  return;
286
614
  }
@@ -291,14 +619,24 @@ export function createMockHost(options = {}) {
291
619
  configurable: true,
292
620
  writable: true,
293
621
  });
294
- // Merge theme + forward-compat domain/maturity into the init context.
622
+ // Merge theme into the init context.
295
623
  const baseContext = options.context ?? { slotId: 'app.page' };
296
- const context = {
297
- ...baseContext,
298
- theme,
299
- ...(options.domain !== undefined ? { domain: options.domain } : {}),
300
- ...(options.maturity !== undefined ? { maturity: options.maturity } : {}),
301
- };
624
+ const context = { ...baseContext, theme };
625
+ // Color-domain maturity (civitai #2670). Resolve the ceiling by precedence:
626
+ // explicit maxBrowsingLevel > maturity convenience > domain-derived. Only
627
+ // EMIT a field when the corresponding option was set, so the default mock
628
+ // host stays a #2670-predating host (the hook fail-closes to SFW).
629
+ const resolvedCeiling = options.maxBrowsingLevel !== undefined
630
+ ? options.maxBrowsingLevel
631
+ : options.maturity === 'sfw'
632
+ ? SFW_LEVELS
633
+ : options.maturity === 'mature'
634
+ ? ALL_LEVELS
635
+ : options.domain !== undefined
636
+ ? options.domain === 'red'
637
+ ? ALL_LEVELS
638
+ : SFW_LEVELS
639
+ : undefined;
302
640
  const initPayload = {
303
641
  blockInstanceId,
304
642
  blockId,
@@ -309,9 +647,10 @@ export function createMockHost(options = {}) {
309
647
  viewer,
310
648
  theme,
311
649
  renderMode: 'iframe',
650
+ ...(options.domain !== undefined ? { domain: options.domain } : {}),
651
+ ...(resolvedCeiling !== undefined ? { maxBrowsingLevel: resolvedCeiling } : {}),
312
652
  };
313
- const initTimer = setTimeout(() => dispatchToBlock({ type: 'BLOCK_INIT', payload: initPayload }), 0);
314
- timers.add(initTimer);
653
+ after(0, () => dispatchToBlock({ type: 'BLOCK_INIT', payload: initPayload }));
315
654
  let torn = false;
316
655
  teardown = () => {
317
656
  if (torn)
@@ -329,6 +668,43 @@ export function createMockHost(options = {}) {
329
668
  };
330
669
  return teardown;
331
670
  }
332
- return { install };
671
+ function setScenario(patch) {
672
+ if (patch.failMode !== undefined)
673
+ failMode = patch.failMode;
674
+ if (patch.pollsUntilDone !== undefined)
675
+ pollsUntilDone = patch.pollsUntilDone;
676
+ if (patch.cost !== undefined)
677
+ legacyCost = patch.cost;
678
+ if (patch.cannedPicks !== undefined)
679
+ cannedPicks = patch.cannedPicks;
680
+ if (patch.generation !== undefined)
681
+ gen = { ...gen, ...patch.generation };
682
+ if (patch.buzz !== undefined)
683
+ buzz = { ...buzz, ...patch.buzz };
684
+ if (patch.storage !== undefined) {
685
+ // Only the live-tunable storage knob (`failNext`) is applied mid-session;
686
+ // seed/quota are install-time (re-install to change the backing store).
687
+ if (patch.storage.failNext !== undefined)
688
+ storageFailNext = patch.storage.failNext;
689
+ }
690
+ }
691
+ const buzzHandle = {
692
+ getBalance: () => buzz.balance,
693
+ setBalance: (n) => {
694
+ buzz.balance = n;
695
+ },
696
+ };
697
+ return { install, setScenario, buzz: buzzHandle };
698
+ }
699
+ /** btoa/atob that work in both browser + node (happy-dom + vitest). */
700
+ function safeBtoa(s) {
701
+ if (typeof btoa === 'function')
702
+ return btoa(s);
703
+ return Buffer.from(s, 'utf-8').toString('base64');
704
+ }
705
+ function safeAtob(s) {
706
+ if (typeof atob === 'function')
707
+ return atob(s);
708
+ return Buffer.from(s, 'base64').toString('utf-8');
333
709
  }
334
710
  //# sourceMappingURL=mockHost.js.map