create-ailk 0.29.1 → 0.31.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/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +47 -14
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -14,6 +14,13 @@ jest.mock("botid/server", () => ({
|
|
|
14
14
|
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
15
15
|
}));
|
|
16
16
|
|
|
17
|
+
// The request's headers (#10715): every case decides them here, a direct
|
|
18
|
+
// request with no proxy marker unless the case says otherwise.
|
|
19
|
+
const headersMock = jest.fn();
|
|
20
|
+
jest.mock("next/headers", () => ({
|
|
21
|
+
headers: (...args: unknown[]) => headersMock(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
17
24
|
const BOT = {
|
|
18
25
|
isHuman: false,
|
|
19
26
|
isBot: true,
|
|
@@ -42,6 +49,8 @@ let warnSpy: jest.SpyInstance;
|
|
|
42
49
|
|
|
43
50
|
beforeEach(() => {
|
|
44
51
|
checkBotIdMock.mockReset();
|
|
52
|
+
headersMock.mockReset();
|
|
53
|
+
headersMock.mockResolvedValue(new Headers({ host: "spoke.example" }));
|
|
45
54
|
for (const name of ENV_KEYS) delete process.env[name];
|
|
46
55
|
process.env.VERCEL = "1";
|
|
47
56
|
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
@@ -78,10 +87,13 @@ describe("AC-1 — runBotCheck's verdict (#9889 D3, one rule for every write)",
|
|
|
78
87
|
checkBotIdMock.mockResolvedValue(verdict);
|
|
79
88
|
expect(await runBotCheck(CHECK)).toBe("bot");
|
|
80
89
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
81
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
91
|
+
"[bot-check] bot refused",
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
path: "testAction",
|
|
94
|
+
outcome: "bot-detected",
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
85
97
|
},
|
|
86
98
|
);
|
|
87
99
|
|
|
@@ -252,3 +264,234 @@ describe("AC-4 — the switches and BotID's client list", () => {
|
|
|
252
264
|
expect(compileBotIdPath(entry?.path ?? "").test(pathname)).toBe(true);
|
|
253
265
|
});
|
|
254
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("#10715 — a refusal names its cause", () => {
|
|
269
|
+
const DIRECT = { host: "spoke.example" };
|
|
270
|
+
const PROXIED = {
|
|
271
|
+
host: "spoke.example",
|
|
272
|
+
"x-forwarded-host": "www.spoke.example",
|
|
273
|
+
"cf-ray": "8f2b-CLE",
|
|
274
|
+
cookie: "session=secret",
|
|
275
|
+
"x-is-human": "challenge-token",
|
|
276
|
+
};
|
|
277
|
+
const VERDICT = {
|
|
278
|
+
...BOT,
|
|
279
|
+
verifiedBotName: undefined,
|
|
280
|
+
verifiedBotCategory: undefined,
|
|
281
|
+
classificationReason: "integrity-check-failed",
|
|
282
|
+
};
|
|
283
|
+
const FIX =
|
|
284
|
+
"Serve the site direct to Vercel: set its Cloudflare DNS record to DNS-only (no orange cloud), never behind a reverse proxy (#10715).";
|
|
285
|
+
|
|
286
|
+
/** Runs `run` in a fresh copy of the module, so its once-per-process flag starts unset. */
|
|
287
|
+
async function inFreshModule(
|
|
288
|
+
run: (fresh: typeof import("../bot-check")) => Promise<void>,
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
await jest.isolateModulesAsync(async () => {
|
|
291
|
+
await run(await import("../bot-check"));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
it("a direct refusal logs the full shape: the verdict's detail, both hosts and proxied false", async () => {
|
|
296
|
+
headersMock.mockResolvedValue(new Headers(DIRECT));
|
|
297
|
+
checkBotIdMock.mockResolvedValue(VERDICT);
|
|
298
|
+
await inFreshModule(async (fresh) => {
|
|
299
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("bot");
|
|
300
|
+
});
|
|
301
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(warnSpy.mock.calls[0]).toStrictEqual([
|
|
303
|
+
"[bot-check] bot refused",
|
|
304
|
+
{
|
|
305
|
+
path: "testAction",
|
|
306
|
+
outcome: "bot-detected",
|
|
307
|
+
classificationReason: "integrity-check-failed",
|
|
308
|
+
bypassed: false,
|
|
309
|
+
isVerifiedBot: false,
|
|
310
|
+
host: "spoke.example",
|
|
311
|
+
forwardedHost: null,
|
|
312
|
+
proxied: false,
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("a proxied refusal logs proxied true and no header value but the two hosts", async () => {
|
|
319
|
+
headersMock.mockResolvedValue(new Headers(PROXIED));
|
|
320
|
+
checkBotIdMock.mockResolvedValue({ ...VERDICT, isVerifiedBot: true });
|
|
321
|
+
await inFreshModule(async (fresh) => {
|
|
322
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("bot");
|
|
323
|
+
});
|
|
324
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(warnSpy.mock.calls[0]).toStrictEqual([
|
|
326
|
+
"[bot-check] bot refused",
|
|
327
|
+
{
|
|
328
|
+
path: "testAction",
|
|
329
|
+
outcome: "bot-detected",
|
|
330
|
+
classificationReason: "integrity-check-failed",
|
|
331
|
+
bypassed: false,
|
|
332
|
+
isVerifiedBot: true,
|
|
333
|
+
host: "spoke.example",
|
|
334
|
+
forwardedHost: "www.spoke.example",
|
|
335
|
+
proxied: true,
|
|
336
|
+
},
|
|
337
|
+
]);
|
|
338
|
+
const logged = JSON.stringify([warnSpy.mock.calls, errorSpy.mock.calls]);
|
|
339
|
+
for (const secret of ["8f2b-CLE", "session=secret", "challenge-token"]) {
|
|
340
|
+
expect(logged).not.toContain(secret);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("a verdict with no reason logs classificationReason null", async () => {
|
|
345
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
346
|
+
await inFreshModule(async (fresh) => {
|
|
347
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("bot");
|
|
348
|
+
});
|
|
349
|
+
expect(warnSpy.mock.calls[0]?.[1]).toMatchObject({
|
|
350
|
+
classificationReason: null,
|
|
351
|
+
proxied: false,
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("headers it cannot read log null hosts and proxied false, and still refuse", async () => {
|
|
356
|
+
headersMock.mockImplementation(() => {
|
|
357
|
+
throw new Error("outside a request scope");
|
|
358
|
+
});
|
|
359
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
360
|
+
await inFreshModule(async (fresh) => {
|
|
361
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("bot");
|
|
362
|
+
});
|
|
363
|
+
expect(warnSpy.mock.calls[0]?.[1]).toMatchObject({
|
|
364
|
+
host: null,
|
|
365
|
+
forwardedHost: null,
|
|
366
|
+
proxied: false,
|
|
367
|
+
});
|
|
368
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it.each([
|
|
372
|
+
["a human", HUMAN, "pass"],
|
|
373
|
+
["a bot", BOT, "bot"],
|
|
374
|
+
] as const)(
|
|
375
|
+
"the first proxied check (%s) logs the reverse-proxy error once per process, naming the fix",
|
|
376
|
+
async (_label, verdict, outcome) => {
|
|
377
|
+
headersMock.mockResolvedValue(new Headers(PROXIED));
|
|
378
|
+
checkBotIdMock.mockResolvedValue(verdict);
|
|
379
|
+
await inFreshModule(async (fresh) => {
|
|
380
|
+
for (let i = 0; i < 3; i++) {
|
|
381
|
+
expect(await fresh.runBotCheck(CHECK)).toBe(outcome);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
385
|
+
expect(errorSpy.mock.calls[0]).toStrictEqual([
|
|
386
|
+
"[bot-check] reverse proxy detected",
|
|
387
|
+
{
|
|
388
|
+
path: "testAction",
|
|
389
|
+
outcome: "proxied",
|
|
390
|
+
host: "spoke.example",
|
|
391
|
+
forwardedHost: "www.spoke.example",
|
|
392
|
+
fix: FIX,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
it("a proxied check that throws still logs the reverse-proxy error, then the failure", async () => {
|
|
399
|
+
headersMock.mockResolvedValue(new Headers(PROXIED));
|
|
400
|
+
checkBotIdMock.mockRejectedValue(new Error("token abc"));
|
|
401
|
+
await inFreshModule(async (fresh) => {
|
|
402
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("unavailable");
|
|
403
|
+
});
|
|
404
|
+
expect(errorSpy.mock.calls.map(([message]) => message)).toEqual([
|
|
405
|
+
"[bot-check] reverse proxy detected",
|
|
406
|
+
"[bot-check] bot check failed",
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("a direct check never logs the reverse-proxy error", async () => {
|
|
411
|
+
headersMock.mockResolvedValue(new Headers(DIRECT));
|
|
412
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
413
|
+
await inFreshModule(async (fresh) => {
|
|
414
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("pass");
|
|
415
|
+
});
|
|
416
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
417
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("proxied changes no verdict: a proxied human passes, a proxied bot refuses", async () => {
|
|
421
|
+
headersMock.mockResolvedValue(new Headers(PROXIED));
|
|
422
|
+
await inFreshModule(async (fresh) => {
|
|
423
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
424
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("pass");
|
|
425
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
426
|
+
expect(await fresh.runBotCheck(CHECK)).toBe("bot");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("#10715 — AILK_BOT_CHECK=observe runs the check and never refuses", () => {
|
|
432
|
+
it.each(["observe", " OBSERVE ", "Observe"])(
|
|
433
|
+
"AILK_BOT_CHECK=%j leaves the check on for both scopes",
|
|
434
|
+
(value) => {
|
|
435
|
+
process.env.AILK_BOT_CHECK = value;
|
|
436
|
+
expect(botCheckOn("write")).toBe(true);
|
|
437
|
+
expect(botCheckOn("booking")).toBe(true);
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
it.each([
|
|
442
|
+
["a bot", BOT, true],
|
|
443
|
+
["a human", HUMAN, false],
|
|
444
|
+
] as const)(
|
|
445
|
+
"%s passes for every scope, logging the facts and isBot as observed",
|
|
446
|
+
async (_label, verdict, isBot) => {
|
|
447
|
+
process.env.AILK_BOT_CHECK = " Observe ";
|
|
448
|
+
headersMock.mockResolvedValue(new Headers({ host: "spoke.example" }));
|
|
449
|
+
checkBotIdMock.mockResolvedValue({
|
|
450
|
+
...verdict,
|
|
451
|
+
classificationReason: "reason-x",
|
|
452
|
+
});
|
|
453
|
+
for (const scope of ["write", "booking"] as const) {
|
|
454
|
+
expect(await runBotCheck({ ...CHECK, scope })).toBe("pass");
|
|
455
|
+
}
|
|
456
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(2);
|
|
457
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
458
|
+
expect(warnSpy.mock.calls[0]).toStrictEqual([
|
|
459
|
+
"[bot-check] bot check observed",
|
|
460
|
+
{
|
|
461
|
+
path: "testAction",
|
|
462
|
+
outcome: "observed",
|
|
463
|
+
isBot,
|
|
464
|
+
classificationReason: "reason-x",
|
|
465
|
+
bypassed: false,
|
|
466
|
+
isVerifiedBot: false,
|
|
467
|
+
host: "spoke.example",
|
|
468
|
+
forwardedHost: null,
|
|
469
|
+
proxied: false,
|
|
470
|
+
},
|
|
471
|
+
]);
|
|
472
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
it("a check that throws logs the failure and passes", async () => {
|
|
477
|
+
process.env.AILK_BOT_CHECK = "observe";
|
|
478
|
+
checkBotIdMock.mockRejectedValue(new Error("token abc"));
|
|
479
|
+
expect(await runBotCheck(CHECK)).toBe("pass");
|
|
480
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
481
|
+
expect(errorSpy).toHaveBeenCalledWith("[bot-check] bot check failed", {
|
|
482
|
+
path: "testAction",
|
|
483
|
+
outcome: "bot-check-failed",
|
|
484
|
+
error: "Error",
|
|
485
|
+
});
|
|
486
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("AILK_BOOKING_BOT_CHECK=off still passes the booking without asking BotID", async () => {
|
|
490
|
+
process.env.AILK_BOT_CHECK = "observe";
|
|
491
|
+
process.env.AILK_BOOKING_BOT_CHECK = "off";
|
|
492
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
493
|
+
expect(await runBotCheck({ ...CHECK, scope: "booking" })).toBe("pass");
|
|
494
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
495
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// #10553 — with AILK_API_URL unset the API client falls back to
|
|
5
|
+
// localhost:3001; a site server bound to 3001 then fetched its own
|
|
6
|
+
// /v1/flows/newsletter, whose render (the footer reads the magnet on every
|
|
7
|
+
// page) fetched it again, without end. The page's flow read now makes no
|
|
8
|
+
// request to its own address and renders without the magnet, and a hub that
|
|
9
|
+
// does not answer is asked once however many renders read it.
|
|
10
|
+
//
|
|
11
|
+
// Through the real `@working-theory/api-client` (jest maps it to source) with
|
|
12
|
+
// the global `fetch` stubbed; the Server Action modules are stubbed as in
|
|
13
|
+
// in-page-magnet.test.ts.
|
|
14
|
+
|
|
15
|
+
export {};
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
|
|
18
|
+
jest.mock("../../app/[locale]/contact/contact.actions", () => ({
|
|
19
|
+
captureLeadAction: jest.fn(),
|
|
20
|
+
}));
|
|
21
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
|
|
22
|
+
jest.mock("../newsletter.actions", () => ({
|
|
23
|
+
subscribeNewsletterAction: jest.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const ENV_KEYS = [
|
|
27
|
+
"AILK_API_URL",
|
|
28
|
+
"NEXT_PUBLIC_AILK_API_URL",
|
|
29
|
+
"NEXT_PUBLIC_API_URL",
|
|
30
|
+
"AILK_SPOKE_KEY",
|
|
31
|
+
"__NEXT_PRIVATE_ORIGIN",
|
|
32
|
+
] as const;
|
|
33
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
34
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
35
|
+
);
|
|
36
|
+
const priorFetch = global.fetch;
|
|
37
|
+
const fetchMock = jest.fn(async (_url: string) => {
|
|
38
|
+
throw new TypeError("fetch failed");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A fresh `in-page-magnet` module, so the shared read starts empty. */
|
|
42
|
+
function freshModule(): typeof import("../in-page-magnet") {
|
|
43
|
+
let mod: typeof import("../in-page-magnet") | undefined;
|
|
44
|
+
jest.isolateModules(() => {
|
|
45
|
+
mod = jest.requireActual("../in-page-magnet");
|
|
46
|
+
});
|
|
47
|
+
if (mod === undefined) throw new Error("in-page-magnet did not load");
|
|
48
|
+
return mod;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
53
|
+
fetchMock.mockClear();
|
|
54
|
+
global.fetch = fetchMock as jest.Mock;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
global.fetch = priorFetch;
|
|
59
|
+
for (const [name, value] of priorEnv) {
|
|
60
|
+
if (value === undefined) delete process.env[name];
|
|
61
|
+
else process.env[name] = value;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("the flow read with AILK_API_URL unset (#10553)", () => {
|
|
66
|
+
it("on a server bound to the fallback's port, makes no request to its own /v1 and renders without the magnet", async () => {
|
|
67
|
+
process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3001";
|
|
68
|
+
const { readServedMagnet } = freshModule();
|
|
69
|
+
|
|
70
|
+
const renders = await Promise.all(
|
|
71
|
+
Array.from({ length: 20 }, () => readServedMagnet("newsletter")),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(renders.every((config) => config === null)).toBe(true);
|
|
75
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("on any other port, asks the unanswering fallback once for the flow and renders without it", async () => {
|
|
79
|
+
process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3000";
|
|
80
|
+
const { readServedMagnet } = freshModule();
|
|
81
|
+
|
|
82
|
+
const first = await readServedMagnet("newsletter");
|
|
83
|
+
const again = await Promise.all(
|
|
84
|
+
Array.from({ length: 20 }, () => readServedMagnet("newsletter")),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(first).toBeNull();
|
|
88
|
+
expect(again.every((config) => config === null)).toBe(true);
|
|
89
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3001/v1/flows/newsletter");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { ROUTES } from "@working-theory/templates/routes";
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
+
readClosingFooterSettings,
|
|
21
22
|
readSiteChromeSettings,
|
|
22
23
|
resolveChromePage,
|
|
23
24
|
resolveHomeLayout,
|
|
@@ -346,6 +347,95 @@ describe("readSiteChromeSettings and resolveChromePage (#10049 AC-4)", () => {
|
|
|
346
347
|
expect(warn).not.toHaveBeenCalled();
|
|
347
348
|
});
|
|
348
349
|
|
|
350
|
+
describe("readClosingFooterSettings (#10550 AC-1)", () => {
|
|
351
|
+
// Each case's read is cached per call site only through React's `cache`,
|
|
352
|
+
// which is a no-op outside a request, and its warnings are once per
|
|
353
|
+
// process — so every case resets the module.
|
|
354
|
+
function fresh(): typeof import("../page-chrome") {
|
|
355
|
+
let mod: typeof import("../page-chrome") | undefined;
|
|
356
|
+
jest.isolateModules(() => {
|
|
357
|
+
mod = require("../page-chrome") as typeof import("../page-chrome");
|
|
358
|
+
});
|
|
359
|
+
return mod!;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const VALID_FOOTER = [
|
|
363
|
+
"footer:",
|
|
364
|
+
" style: closing",
|
|
365
|
+
" locality: Colorado Springs, CO",
|
|
366
|
+
" actions:",
|
|
367
|
+
" - label: Get in touch →",
|
|
368
|
+
" href: /contact",
|
|
369
|
+
" - label: See my pricing",
|
|
370
|
+
" href: /pricing",
|
|
371
|
+
" legalBar: true",
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
it("reads the three settings beside the style", () => {
|
|
375
|
+
writeSite(VALID_FOOTER);
|
|
376
|
+
const mod = fresh();
|
|
377
|
+
expect(mod.readClosingFooterSettings()).toEqual({
|
|
378
|
+
locality: "Colorado Springs, CO",
|
|
379
|
+
actions: [
|
|
380
|
+
{ label: "Get in touch →", href: "/contact" },
|
|
381
|
+
{ label: "See my pricing", href: "/pricing" },
|
|
382
|
+
],
|
|
383
|
+
legalBar: true,
|
|
384
|
+
});
|
|
385
|
+
expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
|
|
386
|
+
expect(warn).not.toHaveBeenCalled();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("an invalid actions drops itself alone, with one warning that never echoes the value", () => {
|
|
390
|
+
writeSite([
|
|
391
|
+
"footer:",
|
|
392
|
+
" style: closing",
|
|
393
|
+
" locality: Colorado Springs, CO",
|
|
394
|
+
" actions:",
|
|
395
|
+
" - label: x",
|
|
396
|
+
" href: https://x.test",
|
|
397
|
+
" legalBar: true",
|
|
398
|
+
]);
|
|
399
|
+
const mod = fresh();
|
|
400
|
+
const settings = mod.readClosingFooterSettings();
|
|
401
|
+
expect(settings.actions).toBeUndefined();
|
|
402
|
+
expect(settings.locality).toBe("Colorado Springs, CO");
|
|
403
|
+
expect(settings.legalBar).toBe(true);
|
|
404
|
+
expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
|
|
405
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
406
|
+
const message = String(warn.mock.calls[0]?.[0]);
|
|
407
|
+
expect(message).toContain("footer.actions");
|
|
408
|
+
expect(message).not.toContain("https://x.test");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("no footer key is the bar off and no warning", () => {
|
|
412
|
+
writeSite([]);
|
|
413
|
+
expect(fresh().readClosingFooterSettings()).toEqual({ legalBar: false });
|
|
414
|
+
expect(warn).not.toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("an unknown footer key warns once under its path and keeps the style", () => {
|
|
418
|
+
writeSite(["footer:", " style: closing", " colour: red"]);
|
|
419
|
+
const mod = fresh();
|
|
420
|
+
expect(mod.readClosingFooterSettings()).toEqual({ legalBar: false });
|
|
421
|
+
expect(mod.readSiteChromeSettings().footerStyle).toBe("closing");
|
|
422
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
423
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("footer.colour");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("a footer that is not an object warns once under footer", () => {
|
|
427
|
+
writeSite(["footer: closing"]);
|
|
428
|
+
expect(fresh().readClosingFooterSettings()).toEqual({ legalBar: false });
|
|
429
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
430
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("_site.mdx footer:");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("an absent _site.mdx is the bar off and no warning", () => {
|
|
434
|
+
expect(readClosingFooterSettings()).toEqual({ legalBar: false });
|
|
435
|
+
expect(warn).not.toHaveBeenCalled();
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
349
439
|
it("resolveChromePage reads a page's and the home's own chrome frontmatter, and never a blog post", async () => {
|
|
350
440
|
writePage("AboutPage", "about", ["header: bare", "footer: centered"]);
|
|
351
441
|
writePage("HomePage", "home", ["layout: deck"]);
|
|
@@ -214,6 +214,31 @@ describe("AC-1 — forwarding, one entry each", () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
describe("never this site's own address (#10553)", () => {
|
|
218
|
+
const priorOrigin = process.env.__NEXT_PRIVATE_ORIGIN;
|
|
219
|
+
afterEach(() => {
|
|
220
|
+
if (priorOrigin === undefined) delete process.env.__NEXT_PRIVATE_ORIGIN;
|
|
221
|
+
else process.env.__NEXT_PRIVATE_ORIGIN = priorOrigin;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("answers 502 and calls nothing when the API address is this site's own host", async () => {
|
|
225
|
+
process.env.AILK_API_URL = SITE;
|
|
226
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
227
|
+
expect(res.status).toBe(502);
|
|
228
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
229
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("answers 502 and calls nothing when AILK_API_URL is unset and the fallback is this server's own port", async () => {
|
|
233
|
+
delete process.env.AILK_API_URL;
|
|
234
|
+
process.env.__NEXT_PRIVATE_ORIGIN = "http://localhost:3001";
|
|
235
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
236
|
+
expect(res.status).toBe(502);
|
|
237
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
238
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
217
242
|
describe("AC-2 — only the list", () => {
|
|
218
243
|
it.each([
|
|
219
244
|
["POST", "/v1/workspaces"],
|
|
@@ -1010,10 +1035,10 @@ describe("the booking bot check (#9889)", () => {
|
|
|
1010
1035
|
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1011
1036
|
expect(checkBotIdMock.mock.calls[0]).toEqual([]);
|
|
1012
1037
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
1013
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
1014
|
-
|
|
1015
|
-
outcome: "bot-detected",
|
|
1016
|
-
|
|
1038
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1039
|
+
"[relay] bot refused",
|
|
1040
|
+
expect.objectContaining({ path: BOOK, outcome: "bot-detected" }),
|
|
1041
|
+
);
|
|
1017
1042
|
});
|
|
1018
1043
|
});
|
|
1019
1044
|
|
|
@@ -1290,10 +1315,10 @@ describe("the write bot check on the lead routes (#9255)", () => {
|
|
|
1290
1315
|
expect(await errorCode(res)).toBe("bot_detected");
|
|
1291
1316
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
1292
1317
|
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
1293
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
1294
|
-
|
|
1295
|
-
outcome: "bot-detected",
|
|
1296
|
-
|
|
1318
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1319
|
+
"[relay] bot refused",
|
|
1320
|
+
expect.objectContaining({ path, outcome: "bot-detected" }),
|
|
1321
|
+
);
|
|
1297
1322
|
});
|
|
1298
1323
|
|
|
1299
1324
|
it.each(LEAD_ROUTES)(
|