@cosmicdrift/kumiko-testing 0.336.0 → 0.337.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 +4 -4
- package/src/bunfig.ts +27 -9
- package/src/changes.json +18 -0
- package/src/e2e/screenshots.ts +26 -14
- package/src/e2e/timeouts.ts +3 -0
- package/src/index.ts +1 -0
- package/src/integration-runner.ts +1 -1
- package/src/preload/dom.ts +18 -15
- package/src/preload/schema-env-defaults.ts +4 -6
- package/src/scaffold.ts +3 -2
- package/src/seed-tenant.ts +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.337.1",
|
|
4
4
|
"description": "Test template for Kumiko apps: per-flow seedTenant, app test stack, canonical test preloads, bunfig generator and integration runner.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"kumiko-testing": "./bin/kumiko-testing.ts"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
78
|
+
"@cosmicdrift/kumiko-bundled-features": "0.337.1",
|
|
79
|
+
"@cosmicdrift/kumiko-dev-server": "0.337.1",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.337.1",
|
|
81
81
|
"zod": "^4.4.3"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
package/src/bunfig.ts
CHANGED
|
@@ -102,7 +102,9 @@ function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
|
|
|
102
102
|
const blocks: { header: string; lines: string[] }[] = [];
|
|
103
103
|
let current: { header: string; lines: string[] } = { header: "", lines: [] };
|
|
104
104
|
for (const line of content.split("\n")) {
|
|
105
|
-
|
|
105
|
+
// An array-of-tables header keeps its inner brackets in `header`, so it
|
|
106
|
+
// never equals a generated header and is carried over as a foreign block.
|
|
107
|
+
const match = /^\[(\[[^\]]+\]|[^[\]]+)\]\s*$/.exec(line);
|
|
106
108
|
if (match?.[1] !== undefined) {
|
|
107
109
|
blocks.push(current);
|
|
108
110
|
current = { header: match[1], lines: [] };
|
|
@@ -114,11 +116,19 @@ function splitBunfigBlocks(content: string): readonly BunfigBlock[] {
|
|
|
114
116
|
return blocks;
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
const TOML_KEY_SEGMENT = String.raw`"[^"]+"|[A-Za-z_][\w-]*`;
|
|
120
|
+
const TOML_KEY_LINE = new RegExp(
|
|
121
|
+
String.raw`^(?:"([^"]+)"|([A-Za-z_][\w-]*))(?:\s*\.\s*(?:${TOML_KEY_SEGMENT}))*\s*=`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Dotted and quoted keys resolve to their first segment, which is the key
|
|
125
|
+
// the template either owns or doesn't.
|
|
117
126
|
function keysIn(lines: readonly string[]): readonly string[] {
|
|
118
127
|
const keys: string[] = [];
|
|
119
128
|
for (const line of lines) {
|
|
120
|
-
const match =
|
|
121
|
-
|
|
129
|
+
const match = TOML_KEY_LINE.exec(line.trim());
|
|
130
|
+
const key = match?.[1] ?? match?.[2];
|
|
131
|
+
if (key !== undefined) keys.push(key);
|
|
122
132
|
}
|
|
123
133
|
return keys;
|
|
124
134
|
}
|
|
@@ -137,9 +147,12 @@ type MergedArrayKey = (typeof MERGED_ARRAY_KEYS)[number];
|
|
|
137
147
|
|
|
138
148
|
const CURRENT_PACKAGE_PRELOADS = new Set<string>([...Object.values(PRELOADS).flat(), DOM_PRELOAD]);
|
|
139
149
|
|
|
140
|
-
// Old app-local path
|
|
141
|
-
// regenerated doesn't run
|
|
142
|
-
|
|
150
|
+
// Old app-local path -> its replacement; dropped only when the replacement is
|
|
151
|
+
// generated for this variant, so an app that hasn't regenerated doesn't run
|
|
152
|
+
// DOM setup twice but a variant without it keeps its own setup.
|
|
153
|
+
const SUPERSEDED_PRELOADS: ReadonlyMap<string, string> = new Map([
|
|
154
|
+
["./test-setup/dom.preload.ts", DOM_PRELOAD],
|
|
155
|
+
]);
|
|
143
156
|
|
|
144
157
|
// Ignore patterns the generator owns across all variants. One it omits for
|
|
145
158
|
// this variant (`**/*.test.tsx` with --dom, `**/*.integration.test.ts` in the
|
|
@@ -153,9 +166,14 @@ const TEMPLATE_OWNED_IGNORE_PATTERNS = new Set<string>([
|
|
|
153
166
|
]),
|
|
154
167
|
]);
|
|
155
168
|
|
|
156
|
-
function isKeepableExtra(
|
|
169
|
+
function isKeepableExtra(
|
|
170
|
+
key: MergedArrayKey,
|
|
171
|
+
entry: string,
|
|
172
|
+
generatedValues: readonly string[],
|
|
173
|
+
): boolean {
|
|
157
174
|
if (key !== "preload") return !TEMPLATE_OWNED_IGNORE_PATTERNS.has(entry);
|
|
158
|
-
|
|
175
|
+
const replacement = SUPERSEDED_PRELOADS.get(entry);
|
|
176
|
+
if (replacement !== undefined) return !generatedValues.includes(replacement);
|
|
159
177
|
if (entry.startsWith(PRELOAD_PREFIX)) return CURRENT_PACKAGE_PRELOADS.has(entry);
|
|
160
178
|
return true;
|
|
161
179
|
}
|
|
@@ -232,7 +250,7 @@ export function mergeBunfig(generated: string, existingContent: string): BunfigM
|
|
|
232
250
|
const generatedValues = arrayField(generatedTest, key);
|
|
233
251
|
const existingValues = arrayField(existingTest, key);
|
|
234
252
|
const extras = existingValues.filter(
|
|
235
|
-
(item) => !generatedValues.includes(item) && isKeepableExtra(key, item),
|
|
253
|
+
(item) => !generatedValues.includes(item) && isKeepableExtra(key, item, generatedValues),
|
|
236
254
|
);
|
|
237
255
|
content = appendArrayExtras(content, key, extras);
|
|
238
256
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.337.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "bunfig merge reports dotted/quoted keys and keeps array tables, empty schema env defaults are replaced, integration dir expansion prunes node_modules, seedTenant gains unsubscribeSse",
|
|
6
|
+
"detail": "`kumiko-testing bunfig` now reports dotted and quoted keys as unknown and keeps `[[array-of-tables]]` blocks instead of folding them into `[test]`, and drops the superseded `./test-setup/dom.preload.ts` only when the DOM preload is generated. The schema-env-defaults preload treats empty values like unset ones for every key. `kumiko-testing integration <dir>` skips `node_modules`, `dist` and `e2e` while expanding a directory. `seedTenant` returns `unsubscribeSse` to detach the tenant's SSE client from `events.sse`. A real-provider scenario `waitFor` now times out after `E2E_TIMEOUT_MS.realWait` (180 s), before the 240 s test timeout."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.336.1",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "DOM preload bounds the printed output of SVG elements as well",
|
|
12
|
+
"detail": "The DOM preload's bounded print of nodes now covers SVG elements too. Before, printing an `<svg>` in a failed assertion dumped the whole happy-dom object graph (about 10 MB)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.336.1",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Scaffolded test:real only matches *.real.test.ts files",
|
|
18
|
+
"detail": "The scaffolded `test:real` script now filters on `.real.test.ts` instead of `real.test.ts`, so files such as `unreal.test.ts` no longer run under the real-provider environment."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.335.0",
|
|
4
22
|
"type": "fix",
|
package/src/e2e/screenshots.ts
CHANGED
|
@@ -216,6 +216,23 @@ async function restoreIdentitiesOnPage(
|
|
|
216
216
|
if (mappings.length > 0) await page.evaluate(restoreIdentitiesInDocument);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Order matters: beforeCapture first (it may hide/mask), identities after, so
|
|
220
|
+
// the replaced text is what the screenshot sees. Exported for unit tests.
|
|
221
|
+
export async function captureScenario(
|
|
222
|
+
page: Page,
|
|
223
|
+
scenario: Pick<Scenario, "beforeCapture" | "fullPage" | "captureStyle">,
|
|
224
|
+
mappings: readonly PresentIdentity[],
|
|
225
|
+
screenshotOptions: Omit<NonNullable<Parameters<Page["screenshot"]>[0]>, "fullPage" | "style">,
|
|
226
|
+
): Promise<Buffer> {
|
|
227
|
+
if (scenario.beforeCapture) await scenario.beforeCapture(page);
|
|
228
|
+
await presentIdentitiesOnPage(page, mappings);
|
|
229
|
+
return page.screenshot({
|
|
230
|
+
...screenshotOptions,
|
|
231
|
+
fullPage: scenario.fullPage ?? false,
|
|
232
|
+
...(scenario.captureStyle !== undefined && { style: scenario.captureStyle }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
219
236
|
function collectPresentIdentities(): {
|
|
220
237
|
readonly mappings: readonly PresentIdentity[];
|
|
221
238
|
readonly register: (mappings: readonly PresentIdentity[]) => void;
|
|
@@ -230,6 +247,10 @@ function collectPresentIdentities(): {
|
|
|
230
247
|
};
|
|
231
248
|
}
|
|
232
249
|
|
|
250
|
+
export function scenarioWaitForTimeout(realProviderRun: boolean): number {
|
|
251
|
+
return realProviderRun ? E2E_TIMEOUT_MS.realWait : E2E_TIMEOUT_MS.navigation;
|
|
252
|
+
}
|
|
253
|
+
|
|
233
254
|
async function openScenario(
|
|
234
255
|
page: Page,
|
|
235
256
|
s: Scenario,
|
|
@@ -242,8 +263,9 @@ async function openScenario(
|
|
|
242
263
|
|
|
243
264
|
if (s.waitFor) {
|
|
244
265
|
// Real-provider scenarios wait on LLM/OCR latency before the page is ready.
|
|
245
|
-
|
|
246
|
-
|
|
266
|
+
await expect(page.locator(s.waitFor).first()).toBeVisible({
|
|
267
|
+
timeout: scenarioWaitForTimeout(isRealProviderRun()),
|
|
268
|
+
});
|
|
247
269
|
}
|
|
248
270
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
249
271
|
}
|
|
@@ -303,14 +325,8 @@ export function runScreenshots(scenarios: readonly Scenario[], opts: FlatOptions
|
|
|
303
325
|
seedTenant,
|
|
304
326
|
presentIdentities: identities.register,
|
|
305
327
|
});
|
|
306
|
-
if (s.beforeCapture) await s.beforeCapture(page);
|
|
307
|
-
await presentIdentitiesOnPage(page, identities.mappings);
|
|
308
328
|
const path = `${outDir}/${s.name}.png`;
|
|
309
|
-
await page.
|
|
310
|
-
path,
|
|
311
|
-
fullPage: s.fullPage ?? false,
|
|
312
|
-
...(s.captureStyle !== undefined && { style: s.captureStyle }),
|
|
313
|
-
});
|
|
329
|
+
await captureScenario(page, s, identities.mappings, { path });
|
|
314
330
|
expect.soft(statSync(path).size).toBeGreaterThan(MIN_BYTES);
|
|
315
331
|
},
|
|
316
332
|
);
|
|
@@ -570,19 +586,15 @@ export function runMatrix<T extends string>(
|
|
|
570
586
|
for (const vp of plan.viewports) {
|
|
571
587
|
if (plan.mode === "desktop") await page.setViewportSize(viewportSizes[vp]);
|
|
572
588
|
await waitForSettledPage(page, inFlightDataRequests);
|
|
573
|
-
if (s.beforeCapture) await s.beforeCapture(page);
|
|
574
|
-
await presentIdentitiesOnPage(page, identities.mappings);
|
|
575
589
|
const dir = `${projectBaseDir}/${s.name}/${locale}/${theme}`;
|
|
576
590
|
mkdirSync(dir, { recursive: true });
|
|
577
591
|
const path = `${dir}/${vp}.png`;
|
|
578
592
|
// animations: "disabled" jumps to end-state at the engine level — immune to CSS specificity, unlike an addStyleTag injection.
|
|
579
593
|
// screenshot() returns the same bytes it writes to `path` — reuse
|
|
580
594
|
// them for both checks instead of a statSync/read-back roundtrip.
|
|
581
|
-
const buffer = await page.
|
|
595
|
+
const buffer = await captureScenario(page, s, identities.mappings, {
|
|
582
596
|
path,
|
|
583
|
-
fullPage: s.fullPage ?? false,
|
|
584
597
|
animations: "disabled",
|
|
585
|
-
...(s.captureStyle !== undefined && { style: s.captureStyle }),
|
|
586
598
|
});
|
|
587
599
|
expect.soft(buffer.length).toBeGreaterThan(MIN_BYTES);
|
|
588
600
|
digests.push({
|
package/src/e2e/timeouts.ts
CHANGED
|
@@ -10,4 +10,7 @@ export const E2E_TIMEOUT_MS = {
|
|
|
10
10
|
poll: 15_000,
|
|
11
11
|
webServer: 90_000,
|
|
12
12
|
real: 240_000,
|
|
13
|
+
// Selector wait of a real-provider scenario; below `real` so the locator error
|
|
14
|
+
// (selector, scenario) fires before the generic test timeout swallows it.
|
|
15
|
+
realWait: 180_000,
|
|
13
16
|
} as const;
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function selectIntegrationFiles(paths: readonly string[]): string[] {
|
|
|
34
34
|
|
|
35
35
|
function listDirectoryFiles(path: string): string[] | undefined {
|
|
36
36
|
if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) return undefined;
|
|
37
|
-
return
|
|
37
|
+
return listIntegrationTestFiles(path);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Resolves `kumiko-testing integration`'s positional file args against `cwd`
|
package/src/preload/dom.ts
CHANGED
|
@@ -49,9 +49,8 @@ if (typeof globalThis.window === "undefined") {
|
|
|
49
49
|
// time. A static import would evaluate screen before happy-dom registers, so
|
|
50
50
|
// every screen query would throw TypeError. Hence require() only after the
|
|
51
51
|
// registration above.
|
|
52
|
-
const { cleanup } =
|
|
53
|
-
|
|
54
|
-
};
|
|
52
|
+
const { cleanup } =
|
|
53
|
+
require("@testing-library/react/pure") as typeof import("@testing-library/react/pure");
|
|
55
54
|
|
|
56
55
|
const HTML_PRINT_LIMIT = 2000;
|
|
57
56
|
|
|
@@ -65,15 +64,19 @@ if (typeof globalThis.HTMLElement !== "undefined") {
|
|
|
65
64
|
if (proto["releasePointerCapture"] === undefined)
|
|
66
65
|
proto["releasePointerCapture"] = () => undefined;
|
|
67
66
|
if (proto["scrollIntoView"] === undefined) proto["scrollIntoView"] = () => undefined;
|
|
67
|
+
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
// Without this, printing a happy-dom node walks its whole object graph:
|
|
70
|
+
// ownerDocument plus every React fiber property, which is 15 MB of string
|
|
71
|
+
// for a two-element tree and 0.5-1.7 s per call. A failed assertion inside
|
|
72
|
+
// waitFor pays that on every poll and blocks the loop long enough to starve
|
|
73
|
+
// React's commit and waitFor's own timeout. On Element, not
|
|
74
|
+
// HTMLElement: SVGElement does not inherit from HTMLElement.
|
|
75
|
+
if (typeof globalThis.Element !== "undefined") {
|
|
76
|
+
const elementProto = globalThis.Element.prototype as unknown as Record<string | symbol, unknown>;
|
|
74
77
|
const inspect = Symbol.for("nodejs.util.inspect.custom");
|
|
75
|
-
if (
|
|
76
|
-
|
|
78
|
+
if (elementProto[inspect] === undefined) {
|
|
79
|
+
elementProto[inspect] = function (this: Element): string {
|
|
77
80
|
const html = this.outerHTML;
|
|
78
81
|
return html.length > HTML_PRINT_LIMIT ? `${html.slice(0, HTML_PRINT_LIMIT)}…` : html;
|
|
79
82
|
};
|
|
@@ -84,15 +87,15 @@ if (typeof globalThis.HTMLElement !== "undefined") {
|
|
|
84
87
|
// test files in one process, so without afterEach, React components from
|
|
85
88
|
// file N stay mounted in file N+1's DOM.
|
|
86
89
|
//
|
|
87
|
-
// FIVE leak sources:
|
|
90
|
+
// FIVE leak sources (b is NOT cleaned here):
|
|
88
91
|
//
|
|
89
92
|
// a) testing-library/react container: cleanup() unmounts and removes
|
|
90
93
|
// every container node render() created.
|
|
91
94
|
//
|
|
92
|
-
// b)
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
95
|
+
// b) Containers not created via testing-library/react (e.g. #root via
|
|
96
|
+
// ReactDOM.createRoot + renderShell) are not cleaned here: the test
|
|
97
|
+
// file must unmount them itself, since body.replaceChildren() would
|
|
98
|
+
// trigger React effects outside act().
|
|
96
99
|
//
|
|
97
100
|
// c) Radix DismissableLayer sets body.style.pointerEvents='none' when
|
|
98
101
|
// opening a Dialog/Popover/Dropdown.
|
|
@@ -6,13 +6,11 @@ import { SCHEMA_ENV_DEFAULTS } from "./schema-env-defaults-values";
|
|
|
6
6
|
const MIN_JWT_SECRET_LENGTH = 32;
|
|
7
7
|
|
|
8
8
|
function isTooShortJwtSecret(key: string, value: string | undefined): boolean {
|
|
9
|
-
return key === "JWT_SECRET" &&
|
|
9
|
+
return key === "JWT_SECRET" && value !== undefined && value.length < MIN_JWT_SECRET_LENGTH;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// An empty value counts as unset: `${{ secrets.X }}` in GitHub Actions yields "" for a missing secret.
|
|
12
13
|
for (const [key, value] of Object.entries(SCHEMA_ENV_DEFAULTS)) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
process.env[key] ??= value;
|
|
14
|
+
const current = process.env[key];
|
|
15
|
+
if (!current || isTooShortJwtSecret(key, current)) process.env[key] = value;
|
|
18
16
|
}
|
package/src/scaffold.ts
CHANGED
|
@@ -194,9 +194,10 @@ export function renderTestSetup(input: RenderTestSetupInput): ScaffoldTestSetup
|
|
|
194
194
|
// verified against bun 1.4), so it can't express "match only *.real.test.ts"
|
|
195
195
|
// on its own — every *.test.ts file matches bun's default test glob too,
|
|
196
196
|
// real.test.ts included. The positional filter narrows the run to files
|
|
197
|
-
// whose path contains "real.test.ts"
|
|
197
|
+
// whose path contains ".real.test.ts" (a bare "real.test.ts" would also
|
|
198
|
+
// match unreal.test.ts), so an unfiltered test:real never
|
|
198
199
|
// picks up the unit suite.
|
|
199
|
-
"test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real} real.test.ts`,
|
|
200
|
+
"test:real": `${REAL_PROVIDERS_ENV}=1 bun --config=${BUNFIG_FILES.real} test --timeout=${TEST_TIMEOUT_MS.real} .real.test.ts`,
|
|
200
201
|
"test:bunfig": "bun kumiko-testing bunfig --hoisted",
|
|
201
202
|
e2e: "bunx --bun playwright test",
|
|
202
203
|
"e2e:real": `${REAL_PROVIDERS_ENV}=1 bunx --bun playwright test`,
|
package/src/seed-tenant.ts
CHANGED
|
@@ -169,24 +169,30 @@ function lightTenantRows(opts: SeedTenantOptions): PersistedTenant {
|
|
|
169
169
|
return { ...identity, admin: lightCredentials(adminEmail), members };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
export type InProcessSeededTenant = SeededTenant & {
|
|
173
|
+
// Stops this tenant's events from landing in the stack's `events.sse`.
|
|
174
|
+
readonly unsubscribeSse: () => void;
|
|
175
|
+
};
|
|
176
|
+
|
|
172
177
|
export async function seedTenant(
|
|
173
178
|
stack: TestStack,
|
|
174
179
|
opts: SeedTenantOptions = {},
|
|
175
|
-
): Promise<
|
|
180
|
+
): Promise<InProcessSeededTenant> {
|
|
176
181
|
const write = stackSeedWriter(stack);
|
|
177
182
|
const persist = opts.persist === true;
|
|
178
183
|
const rows = persist ? await persistTenantRows(write, opts) : lightTenantRows(opts);
|
|
179
184
|
const { id } = rows;
|
|
180
185
|
|
|
181
186
|
// setupTestStack subscribes events.sse only to test tenant 1; a seeded tenant has a fresh random id.
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
const channel = tenantChannel(id);
|
|
188
|
+
const sseClientId = stack.sseBroker.addClient(
|
|
189
|
+
channel,
|
|
184
190
|
(event) => stack.events.sse.push(event),
|
|
185
191
|
() => {},
|
|
186
192
|
);
|
|
187
193
|
|
|
188
194
|
const admin = withSession(rows.admin, id, [ROLES.TenantAdmin]);
|
|
189
|
-
const tenant:
|
|
195
|
+
const tenant: InProcessSeededTenant = {
|
|
190
196
|
id,
|
|
191
197
|
key: rows.key,
|
|
192
198
|
name: rows.name,
|
|
@@ -202,6 +208,7 @@ export async function seedTenant(
|
|
|
202
208
|
id,
|
|
203
209
|
roles,
|
|
204
210
|
),
|
|
211
|
+
unsubscribeSse: () => stack.sseBroker.removeClient(channel, sseClientId),
|
|
205
212
|
api: bindApi(stack, admin.session),
|
|
206
213
|
apiAs: (user) => bindApi(stack, user.session),
|
|
207
214
|
};
|