create-ailk 0.18.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the account layout — the base area's SignedInShell frame
|
|
3
|
-
* (#7430, spec 7423 U4 · D13; the U4 half of AC-17).
|
|
3
|
+
* (#7430, spec 7423 U4 · D13; the U4 half of AC-17; #8231 D1).
|
|
4
4
|
*
|
|
5
5
|
* Covers:
|
|
6
|
-
* 1. The rail: the site logo as the top tile (a link home), Inbox
|
|
7
|
-
*
|
|
6
|
+
* 1. The rail: the site logo as the top tile (a link home), then Inbox and
|
|
7
|
+
* a Library group holding Artifacts — each selected on its own paths,
|
|
8
|
+
* no row on a Settings page — and a footer of the avatar and ? only.
|
|
9
|
+
* No Settings row (#8334: Settings lives in the avatar menu, as on the
|
|
10
|
+
* Admin).
|
|
8
11
|
* 2. The avatar opens the card menu (name, then Settings and Log out side
|
|
9
|
-
* by side);
|
|
12
|
+
* by side); Settings is the one route to /account/general; Log out
|
|
13
|
+
* signs out and routes home.
|
|
10
14
|
* 3. ? opens Contact support (→ /account/support) and, only when the site
|
|
11
15
|
* configures a help URL, Help center.
|
|
12
16
|
* 4. Below md the tab bar reads avatar · Inbox · For you · Help, with no
|
|
@@ -55,13 +59,19 @@ jest.mock("../../../../../auth", () => ({
|
|
|
55
59
|
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
56
60
|
}));
|
|
57
61
|
|
|
58
|
-
const mockCheckoutsMe = jest.fn();
|
|
59
62
|
const mockSubscriptionMe = jest.fn();
|
|
63
|
+
// #8231 D7 — the member reads call on every topology; here every one 404s.
|
|
64
|
+
const mockMemberNotFound = jest.fn();
|
|
60
65
|
jest.mock("@working-theory/api-client", () => ({
|
|
61
66
|
__esModule: true,
|
|
62
67
|
waitlistSignups: { me: jest.fn() },
|
|
63
|
-
flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
|
|
64
68
|
subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
|
|
69
|
+
member: {
|
|
70
|
+
pieces: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
71
|
+
messages: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
72
|
+
purchases: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
73
|
+
appointments: (...args: unknown[]) => mockMemberNotFound(...args),
|
|
74
|
+
},
|
|
65
75
|
}));
|
|
66
76
|
|
|
67
77
|
const mockSignOut = jest.fn();
|
|
@@ -139,8 +149,8 @@ beforeEach(() => {
|
|
|
139
149
|
mockSearch = "";
|
|
140
150
|
delete process.env.AILK_HELP_CENTER_URL;
|
|
141
151
|
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
142
|
-
mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [] });
|
|
143
152
|
mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
|
|
153
|
+
mockMemberNotFound.mockReset().mockResolvedValue({ ok: false, status: 404, body: null });
|
|
144
154
|
mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
145
155
|
mockPush.mockReset();
|
|
146
156
|
mockRefresh.mockReset();
|
|
@@ -167,16 +177,69 @@ describe("the account rail (D13, AC-17)", () => {
|
|
|
167
177
|
expect(within(rail).getByTestId("account-site-logo")).toHaveAttribute("href", "/fr/");
|
|
168
178
|
});
|
|
169
179
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const links = within(rail)
|
|
180
|
+
/** The rail's rows, in DOM order — every link but the logo. */
|
|
181
|
+
function railRows(): HTMLElement[] {
|
|
182
|
+
return within(screen.getByTestId("sidebar-desktop"))
|
|
174
183
|
.getAllByRole("link")
|
|
175
184
|
.filter((link) => link.getAttribute("data-testid") !== "account-site-logo");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
it("the rows are Inbox, then Artifacts under Library — no Settings row (#8231 D1, #8334)", async () => {
|
|
188
|
+
await renderLayout();
|
|
189
|
+
const rows = railRows();
|
|
190
|
+
expect(rows.map((row) => [row.textContent, row.getAttribute("href")])).toEqual([
|
|
191
|
+
["Inbox", "/account"],
|
|
192
|
+
["Artifacts", "/account/artifacts"],
|
|
193
|
+
]);
|
|
194
|
+
expect(rows[0]).toHaveAttribute("aria-current", "page");
|
|
195
|
+
// Artifacts sits in the Library group.
|
|
196
|
+
const heading = screen.getByTestId("sidebar-group-heading-library");
|
|
197
|
+
expect(heading).toHaveTextContent("Library");
|
|
198
|
+
const library = heading.parentElement as HTMLElement;
|
|
199
|
+
expect(within(library).getByText("Artifacts")).toBeInTheDocument();
|
|
200
|
+
// #8334 — Settings is nowhere in the rail, at any viewport: not a row, not
|
|
201
|
+
// a tab. The avatar menu is its one route (the next describe).
|
|
202
|
+
expect(within(screen.getByTestId("sidebar-desktop")).queryByText("Settings")).not.toBeInTheDocument();
|
|
203
|
+
expect(within(screen.getByTestId("bottom-tab-bar")).queryByText("Settings")).not.toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each([
|
|
207
|
+
["/account", "Inbox"],
|
|
208
|
+
["/account/inbox/purchase/x", "Inbox"],
|
|
209
|
+
["/account/artifacts", "Artifacts"],
|
|
210
|
+
["/account/artifacts/p1", "Artifacts"],
|
|
211
|
+
])("on %s only %s is selected (#8231 D1)", async (path, selected) => {
|
|
212
|
+
mockPathname = path;
|
|
213
|
+
await renderLayout();
|
|
214
|
+
const current = railRows().filter((row) => row.getAttribute("aria-current") === "page");
|
|
215
|
+
expect(current.map((row) => row.textContent)).toEqual([selected]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each([
|
|
219
|
+
"/account/support",
|
|
220
|
+
// #8334 — the Settings pages (and the old path that redirects to General)
|
|
221
|
+
// select no row: Settings has no rail row to select.
|
|
222
|
+
"/account/general",
|
|
223
|
+
"/account/billing",
|
|
224
|
+
"/account/settings",
|
|
225
|
+
"/fr/account/billing",
|
|
226
|
+
])("no row is selected on %s (#8231 D1, #8334)", async (path) => {
|
|
227
|
+
mockPathname = path;
|
|
228
|
+
await renderLayout(path.startsWith("/fr/") ? "fr" : "en");
|
|
229
|
+
const rows = railRows();
|
|
230
|
+
expect(rows).toHaveLength(2);
|
|
231
|
+
expect(rows.some((row) => row.hasAttribute("aria-current"))).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("a non-default locale prefixes every row (#8231 D1)", async () => {
|
|
235
|
+
mockPathname = "/fr/account/artifacts";
|
|
236
|
+
await renderLayout("fr");
|
|
237
|
+
const rows = railRows();
|
|
238
|
+
expect(rows.map((row) => row.getAttribute("href"))).toEqual([
|
|
239
|
+
"/fr/account",
|
|
240
|
+
"/fr/account/artifacts",
|
|
241
|
+
]);
|
|
242
|
+
expect(rows[1]).toHaveAttribute("aria-current", "page");
|
|
180
243
|
});
|
|
181
244
|
|
|
182
245
|
it("an Inbox item page keeps Inbox selected", async () => {
|
|
@@ -215,11 +278,28 @@ describe("the avatar menu (D13)", () => {
|
|
|
215
278
|
const actions = within(menu).getByTestId("account-menu-actions");
|
|
216
279
|
expect(within(actions).getByText("Settings").closest("a")).toHaveAttribute(
|
|
217
280
|
"href",
|
|
218
|
-
"/account/
|
|
281
|
+
"/account/general",
|
|
219
282
|
);
|
|
220
283
|
expect(within(actions).getByTestId("account-menu-sign-out")).toHaveTextContent("Log out");
|
|
221
284
|
});
|
|
222
285
|
|
|
286
|
+
it("Settings in the menu is the one route to General, locale-prefixed (#8334)", async () => {
|
|
287
|
+
mockPathname = "/fr/account";
|
|
288
|
+
await renderLayout("fr");
|
|
289
|
+
openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
|
|
290
|
+
const actions = within(screen.getByTestId("account-menu-content")).getByTestId(
|
|
291
|
+
"account-menu-actions",
|
|
292
|
+
);
|
|
293
|
+
expect(within(actions).getByText("Settings").closest("a")).toHaveAttribute(
|
|
294
|
+
"href",
|
|
295
|
+
"/fr/account/general",
|
|
296
|
+
);
|
|
297
|
+
// The rail offers no second Settings link.
|
|
298
|
+
expect(
|
|
299
|
+
within(screen.getByTestId("sidebar-desktop")).queryByRole("link", { name: "Settings" }),
|
|
300
|
+
).not.toBeInTheDocument();
|
|
301
|
+
});
|
|
302
|
+
|
|
223
303
|
it("Log out signs out, then routes home and refreshes", async () => {
|
|
224
304
|
await renderLayout();
|
|
225
305
|
openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
|
|
@@ -370,3 +450,22 @@ describe("no session", () => {
|
|
|
370
450
|
expect(screen.queryByTestId("sidebar-desktop")).not.toBeInTheDocument();
|
|
371
451
|
});
|
|
372
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("the shell marker (#8337 D4, AC-6)", () => {
|
|
455
|
+
it("with a session, exactly one [data-ailk-shell=\"account\"] wraps the shell", async () => {
|
|
456
|
+
await renderLayout();
|
|
457
|
+
const markers = document.querySelectorAll("[data-ailk-shell]");
|
|
458
|
+
expect(markers).toHaveLength(1);
|
|
459
|
+
const marker = markers[0] as HTMLElement;
|
|
460
|
+
expect(marker.getAttribute("data-ailk-shell")).toBe("account");
|
|
461
|
+
expect(marker.getAttribute("data-testid")).toBe("account-shell");
|
|
462
|
+
expect(Array.from(marker.classList)).toEqual(["contents"]);
|
|
463
|
+
expect(within(marker).getByTestId("dashboard-shell")).toBeInTheDocument();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("with no session, no [data-ailk-shell] exists", async () => {
|
|
467
|
+
mockGetSession.mockResolvedValue(null);
|
|
468
|
+
await renderLayout();
|
|
469
|
+
expect(document.querySelector("[data-ailk-shell]")).not.toBeInTheDocument();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the member New menu (#8233, spec
|
|
3
|
+
* docs/specs/8233-member-new-menu-the-new-spec.md D1–D4; AC-1, AC-2, AC-6).
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. New over the Inbox offers exactly Schedule service · Request quote ·
|
|
7
|
+
* Send message, in that order, as menu items that are not links; each
|
|
8
|
+
* opens a popup over the Inbox titled with its label, and nothing
|
|
9
|
+
* navigates (AC-1).
|
|
10
|
+
* 2. Each row is offered only while its member read answers: a 404, or a
|
|
11
|
+
* switched-off request form, leaves it out; every read 404 (a Free
|
|
12
|
+
* build) leaves no New button and no error row; a failed read drops
|
|
13
|
+
* its row AND shows the Inbox error row (AC-2).
|
|
14
|
+
* 3. Placement: on the Inbox route the frame's phone top bar holds the
|
|
15
|
+
* compact New; elsewhere in /account it holds nothing; outside a frame
|
|
16
|
+
* only the desktop New shows, in the h1's header row (AC-6).
|
|
17
|
+
*/
|
|
18
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
19
|
+
import type { ReactElement } from "react";
|
|
20
|
+
|
|
21
|
+
// The account suites' next-intl mock: dotted namespaces and dotted keys
|
|
22
|
+
// resolve against en.json, `{name}` placeholders fill from `values`.
|
|
23
|
+
jest.mock("next-intl/server", () => {
|
|
24
|
+
const msgs: unknown = require("../../../../../messages/en.json");
|
|
25
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
26
|
+
path.split(".").reduce<unknown>(
|
|
27
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
28
|
+
obj,
|
|
29
|
+
);
|
|
30
|
+
return {
|
|
31
|
+
__esModule: true,
|
|
32
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
33
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
34
|
+
const messages = dig(msgs, ns);
|
|
35
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
36
|
+
const value = dig(messages, key);
|
|
37
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
38
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
39
|
+
values && name in values ? String(values[name]) : m,
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
jest.mock("next/headers", () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
49
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const mockGetSession = jest.fn();
|
|
53
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
|
|
54
|
+
jest.mock("../../../../../auth", () => ({
|
|
55
|
+
__esModule: true,
|
|
56
|
+
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const mockSubscriptionMe = jest.fn();
|
|
60
|
+
const mockMemberPieces = jest.fn();
|
|
61
|
+
const mockMemberMessages = jest.fn();
|
|
62
|
+
const mockMemberPurchases = jest.fn();
|
|
63
|
+
const mockMemberAppointments = jest.fn();
|
|
64
|
+
const mockMemberMeetingTypes = jest.fn();
|
|
65
|
+
const mockMemberRequestForm = jest.fn();
|
|
66
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
67
|
+
__esModule: true,
|
|
68
|
+
waitlistSignups: { me: jest.fn() },
|
|
69
|
+
subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
|
|
70
|
+
member: {
|
|
71
|
+
pieces: (...args: unknown[]) => mockMemberPieces(...args),
|
|
72
|
+
messages: (...args: unknown[]) => mockMemberMessages(...args),
|
|
73
|
+
purchases: (...args: unknown[]) => mockMemberPurchases(...args),
|
|
74
|
+
appointments: (...args: unknown[]) => mockMemberAppointments(...args),
|
|
75
|
+
meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
|
|
76
|
+
requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
jest.mock("better-auth/react", () => ({
|
|
81
|
+
__esModule: true,
|
|
82
|
+
createAuthClient: () => ({ signOut: jest.fn() }),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const mockPush = jest.fn();
|
|
86
|
+
const mockRefresh = jest.fn();
|
|
87
|
+
let mockPathname = "/account";
|
|
88
|
+
jest.mock("next/navigation", () => ({
|
|
89
|
+
__esModule: true,
|
|
90
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
91
|
+
usePathname: () => mockPathname,
|
|
92
|
+
useSearchParams: () => new URLSearchParams(),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
import { PhoneTabProvider } from "@working-theory/templates/account";
|
|
96
|
+
import { requestQuoteFlowConfig } from "@working-theory/validation";
|
|
97
|
+
|
|
98
|
+
import AccountLayout from "../layout";
|
|
99
|
+
import { NewMenu } from "../new-menu";
|
|
100
|
+
import Page from "../page";
|
|
101
|
+
|
|
102
|
+
const SESSION = {
|
|
103
|
+
user: {
|
|
104
|
+
id: "user_1",
|
|
105
|
+
name: "Ada",
|
|
106
|
+
email: "ada@example.com",
|
|
107
|
+
emailVerified: true,
|
|
108
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** The cookie a single site forwards (`forwardedCookieInit()`, no Origin here). */
|
|
113
|
+
const INIT = { headers: { cookie: "session=abc" } };
|
|
114
|
+
|
|
115
|
+
const notFound = {
|
|
116
|
+
ok: false,
|
|
117
|
+
status: 404,
|
|
118
|
+
body: { ok: false, error: { code: "not_found", message: "Not found" } },
|
|
119
|
+
};
|
|
120
|
+
const failure = (status: number) => ({
|
|
121
|
+
ok: false,
|
|
122
|
+
status,
|
|
123
|
+
body: { ok: false, error: { code: "failed", message: "x" } },
|
|
124
|
+
});
|
|
125
|
+
const ok = <T,>(body: T) => ({ ok: true, body });
|
|
126
|
+
|
|
127
|
+
const TYPE = { slug: "consult", title: "Consultation", durationMinutes: 30 };
|
|
128
|
+
const FORM = {
|
|
129
|
+
form: { title: "Request a quote", defaultLocale: "en", steps: requestQuoteFlowConfig.steps },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const LABELS = ["Schedule service", "Request quote", "Send message"];
|
|
133
|
+
|
|
134
|
+
beforeAll(() => {
|
|
135
|
+
// Radix menus open on pointerdown; jsdom has no PointerEvent.
|
|
136
|
+
if (typeof globalThis.PointerEvent === "undefined") {
|
|
137
|
+
// eslint-disable-next-line no-restricted-syntax -- a MouseEvent stands in for jsdom's missing PointerEvent
|
|
138
|
+
globalThis.PointerEvent = MouseEvent as unknown as typeof PointerEvent;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
window.localStorage.clear();
|
|
144
|
+
mockPathname = "/account";
|
|
145
|
+
mockPush.mockReset();
|
|
146
|
+
mockRefresh.mockReset();
|
|
147
|
+
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
148
|
+
mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
|
|
149
|
+
mockMemberPieces.mockReset().mockResolvedValue(notFound);
|
|
150
|
+
mockMemberPurchases.mockReset().mockResolvedValue(notFound);
|
|
151
|
+
mockMemberAppointments.mockReset().mockResolvedValue(notFound);
|
|
152
|
+
// Every action offered unless a case says otherwise.
|
|
153
|
+
mockMemberMeetingTypes.mockReset().mockResolvedValue(ok({ items: [TYPE] }));
|
|
154
|
+
mockMemberRequestForm.mockReset().mockResolvedValue(ok(FORM));
|
|
155
|
+
mockMemberMessages.mockReset().mockResolvedValue(ok({ items: [] }));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function openByPointer(trigger: HTMLElement): void {
|
|
159
|
+
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function renderInbox(): Promise<void> {
|
|
163
|
+
const element = await Page({ params: Promise.resolve({ locale: "en" }) });
|
|
164
|
+
render(<PhoneTabProvider tab="inbox">{element}</PhoneTabProvider>);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The New menu's rows, as their visible names. */
|
|
168
|
+
function openMenu(trigger = screen.getByTestId("account-new-menu-trigger")): string[] {
|
|
169
|
+
openByPointer(trigger);
|
|
170
|
+
const content = screen.getByTestId("account-new-menu-content");
|
|
171
|
+
return within(content)
|
|
172
|
+
.getAllByRole("menuitem")
|
|
173
|
+
.map((item) => item.textContent ?? "");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("New offers the three actions, each a popup over the Inbox (AC-1)", () => {
|
|
177
|
+
it("lists exactly Schedule service · Request quote · Send message, none a link", async () => {
|
|
178
|
+
await renderInbox();
|
|
179
|
+
expect(openMenu()).toEqual(LABELS);
|
|
180
|
+
const content = screen.getByTestId("account-new-menu-content");
|
|
181
|
+
expect(content.querySelector("a")).toBeNull();
|
|
182
|
+
expect(within(content).getByTestId("account-new-menu-item-schedule-service")).toBeInTheDocument();
|
|
183
|
+
expect(within(content).getByTestId("account-new-menu-item-request-quote")).toBeInTheDocument();
|
|
184
|
+
expect(within(content).getByTestId("account-new-menu-item-send-message")).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it.each(LABELS.map((label, i) => [label, i] as const))(
|
|
188
|
+
"%s opens a popup titled with its label over the Inbox, and never navigates",
|
|
189
|
+
async (label, index) => {
|
|
190
|
+
await renderInbox();
|
|
191
|
+
openMenu();
|
|
192
|
+
fireEvent.click(screen.getAllByRole("menuitem")[index]!);
|
|
193
|
+
const popup = await screen.findByTestId("account-new-popup");
|
|
194
|
+
expect(within(popup).getByRole("heading", { name: label })).toBeInTheDocument();
|
|
195
|
+
expect(screen.getByTestId("account-inbox")).toBeInTheDocument();
|
|
196
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
197
|
+
// Close leaves the Inbox as it was, with nothing refreshed.
|
|
198
|
+
fireEvent.click(within(popup).getByRole("button", { name: "Close" }));
|
|
199
|
+
await waitFor(() => expect(screen.queryByTestId("account-new-popup")).not.toBeInTheDocument());
|
|
200
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("each action is offered only while its read answers (AC-2)", () => {
|
|
206
|
+
it("meetingTypes 404 → Request quote and Send message only", async () => {
|
|
207
|
+
mockMemberMeetingTypes.mockResolvedValue(notFound);
|
|
208
|
+
await renderInbox();
|
|
209
|
+
expect(openMenu()).toEqual(["Request quote", "Send message"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it.each([
|
|
213
|
+
["{ form: null }", ok({ form: null })],
|
|
214
|
+
["404", notFound],
|
|
215
|
+
])("requestForm %s → no Request quote", async (_name, answer) => {
|
|
216
|
+
mockMemberRequestForm.mockResolvedValue(answer);
|
|
217
|
+
await renderInbox();
|
|
218
|
+
expect(openMenu()).toEqual(["Schedule service", "Send message"]);
|
|
219
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("meetingTypes { items: [] } → Schedule service is still offered (its popup says why)", async () => {
|
|
223
|
+
mockMemberMeetingTypes.mockResolvedValue(ok({ items: [] }));
|
|
224
|
+
await renderInbox();
|
|
225
|
+
expect(openMenu()).toEqual(LABELS);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("every member read 404 (a Free build) → no New button and no error row", async () => {
|
|
229
|
+
mockMemberMeetingTypes.mockResolvedValue(notFound);
|
|
230
|
+
mockMemberRequestForm.mockResolvedValue(notFound);
|
|
231
|
+
mockMemberMessages.mockResolvedValue(notFound);
|
|
232
|
+
await renderInbox();
|
|
233
|
+
expect(screen.queryByTestId("account-new-menu-trigger")).not.toBeInTheDocument();
|
|
234
|
+
expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
|
|
235
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("requestForm 500 → no Request quote, and the Inbox error row (D12 parity)", async () => {
|
|
239
|
+
mockMemberRequestForm.mockResolvedValue(failure(500));
|
|
240
|
+
await renderInbox();
|
|
241
|
+
expect(openMenu()).toEqual(["Schedule service", "Send message"]);
|
|
242
|
+
expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("each availability read receives the request's init unchanged", async () => {
|
|
246
|
+
await renderInbox();
|
|
247
|
+
expect(mockMemberMeetingTypes).toHaveBeenCalledWith(INIT);
|
|
248
|
+
expect(mockMemberRequestForm).toHaveBeenCalledWith(INIT);
|
|
249
|
+
expect(mockMemberMessages).toHaveBeenCalledWith(INIT);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("signed out → no New button and no read", async () => {
|
|
253
|
+
mockGetSession.mockResolvedValue(null);
|
|
254
|
+
await renderInbox();
|
|
255
|
+
expect(screen.queryByTestId("account-new-menu-trigger")).not.toBeInTheDocument();
|
|
256
|
+
expect(mockMemberMeetingTypes).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("placement — the h1's row from md up, the phone top bar below (AC-6)", () => {
|
|
261
|
+
const ACTIONS = LABELS.map((label, i) => ({
|
|
262
|
+
id: ["schedule-service", "request-quote", "send-message"][i]!,
|
|
263
|
+
label,
|
|
264
|
+
popup: <p>{label} body</p>,
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
function InboxChild(): ReactElement {
|
|
268
|
+
return <NewMenu actions={ACTIONS} labels={{ trigger: "New", close: "Close" }} />;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function renderLayout(): Promise<void> {
|
|
272
|
+
const element = await AccountLayout({
|
|
273
|
+
children: <InboxChild />,
|
|
274
|
+
params: Promise.resolve({ locale: "en" }),
|
|
275
|
+
});
|
|
276
|
+
render(element);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
it("on /account the compact New sits in the top bar's trailing cell and opens the same menu", async () => {
|
|
280
|
+
await renderLayout();
|
|
281
|
+
const end = screen.getByTestId("sidebar-topbar-end");
|
|
282
|
+
const compact = await within(end).findByTestId("account-new-menu-trigger-compact");
|
|
283
|
+
expect(compact).toHaveAccessibleName("New");
|
|
284
|
+
expect(openMenu(compact)).toEqual(LABELS);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("on /account/settings the top bar holds no New slot", async () => {
|
|
288
|
+
mockPathname = "/account/settings";
|
|
289
|
+
await renderLayout();
|
|
290
|
+
expect(screen.queryByTestId("account-top-bar-actions")).not.toBeInTheDocument();
|
|
291
|
+
expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("outside a frame there is no compact New, and New sits in the h1's header row", async () => {
|
|
295
|
+
await renderInbox();
|
|
296
|
+
expect(screen.queryByTestId("account-new-menu-trigger-compact")).not.toBeInTheDocument();
|
|
297
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
298
|
+
expect(heading.parentElement).toContainElement(screen.getByTestId("account-new-menu-trigger"));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("with no action NewMenu renders nothing", () => {
|
|
302
|
+
const { container } = render(<NewMenu actions={[]} labels={{ trigger: "New", close: "Close" }} />);
|
|
303
|
+
expect(container).toBeEmptyDOMElement();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for account/new-popups.tsx — the New actions with their popups bound
|
|
3
|
+
* (#8233 D1, D2, D6–D8; spec AC-7's popup half, moved here with the popups:
|
|
4
|
+
* `lib/account-config.ts` may import nothing under `(authed)`, so the
|
|
5
|
+
* binding lives in the route).
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* 1. `newActions` is `accountNewActions` in menu order, each read untouched
|
|
9
|
+
* (the same `available` function the config exports).
|
|
10
|
+
* 2. Each popup's props carry no credential and no email — only the locale
|
|
11
|
+
* and strings (SEC-8233-1).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
waitlistSignups: { me: jest.fn() },
|
|
17
|
+
subscription: { me: jest.fn() },
|
|
18
|
+
member: {
|
|
19
|
+
pieces: jest.fn(),
|
|
20
|
+
messages: jest.fn(),
|
|
21
|
+
purchases: jest.fn(),
|
|
22
|
+
appointments: jest.fn(),
|
|
23
|
+
meetingTypes: jest.fn(),
|
|
24
|
+
requestForm: jest.fn(),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
AccountContext,
|
|
30
|
+
AccountNewAction,
|
|
31
|
+
} from "@working-theory/templates/account";
|
|
32
|
+
import type { ReactElement } from "react";
|
|
33
|
+
|
|
34
|
+
import { accountNewActions } from "../../../../../lib/account-config";
|
|
35
|
+
import { newActions } from "../new-popups";
|
|
36
|
+
|
|
37
|
+
const INIT = { headers: { cookie: "session=abc" } };
|
|
38
|
+
|
|
39
|
+
function ctx(): AccountContext {
|
|
40
|
+
return {
|
|
41
|
+
locale: "en",
|
|
42
|
+
user: { name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
43
|
+
t: (key) => `t:${key}`,
|
|
44
|
+
init: INIT,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function action(id: string): AccountNewAction {
|
|
49
|
+
const found = newActions.find((a) => a.id === id);
|
|
50
|
+
if (!found) throw new Error(`no New action ${id}`);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("newActions — the config's actions with their popups (#8233 D1, D2)", () => {
|
|
55
|
+
it("is accountNewActions in menu order, each read the config's own", () => {
|
|
56
|
+
expect(newActions.map((a) => [a.id, a.label])).toEqual(
|
|
57
|
+
accountNewActions.map((a) => [a.id, a.label]),
|
|
58
|
+
);
|
|
59
|
+
expect(newActions.map((a) => a.id)).toEqual([
|
|
60
|
+
"schedule-service",
|
|
61
|
+
"request-quote",
|
|
62
|
+
"send-message",
|
|
63
|
+
]);
|
|
64
|
+
newActions.forEach((a, i) => {
|
|
65
|
+
expect(a.available).toBe(accountNewActions[i]!.available);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it.each(["schedule-service", "request-quote", "send-message"])(
|
|
70
|
+
"%s renders a popup whose props carry no cookie and no email (SEC-8233-1)",
|
|
71
|
+
(id) => {
|
|
72
|
+
const popup = action(id).renderPopup(ctx()) as ReactElement<
|
|
73
|
+
Record<string, unknown>
|
|
74
|
+
>;
|
|
75
|
+
const props = JSON.stringify(popup.props);
|
|
76
|
+
expect(props).not.toContain("session=abc");
|
|
77
|
+
expect(props).not.toContain("ada@example.com");
|
|
78
|
+
expect(popup.props).toMatchObject({ locale: "en" });
|
|
79
|
+
expect(Object.keys(popup.props).sort()).toEqual(["labels", "locale"]);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|