create-ailk 0.12.1 → 0.14.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 +12 -4
- package/component-catalog.json +247 -33
- package/component-catalog.md +10 -6
- package/dist/apply-design.d.ts +21 -2
- package/dist/apply-design.js +120 -19
- package/dist/cli.js +1 -0
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +223 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +1 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.js +27 -0
- package/dist/programmatic.js +70 -26
- package/dist/site-chrome.d.ts +38 -0
- package/dist/site-chrome.js +109 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +164 -34
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/.env.example +23 -9
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +24 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/CLAUDE.md +11 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +156 -53
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/lib/site-chrome.ts +12 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +22 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the profile back-link rule (#7744 D7).
|
|
3
|
+
*
|
|
4
|
+
* On a deck home, a team profile's back arrow returns to the home's people
|
|
5
|
+
* slide (`#slide-people`, #7740's anchor for the section id `people`);
|
|
6
|
+
* everywhere else it goes home. Covers bare and locale-prefixed profiles, the
|
|
7
|
+
* `/team` index, near misses, a non-deck home and the absent-header fail-safe.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
backLinkHref,
|
|
12
|
+
isTeamProfilePath,
|
|
13
|
+
PEOPLE_SLIDE_ANCHOR,
|
|
14
|
+
} from "../profile-back-link";
|
|
15
|
+
|
|
16
|
+
const LOCALES = ["en", "fr"];
|
|
17
|
+
|
|
18
|
+
describe("isTeamProfilePath", () => {
|
|
19
|
+
it.each(["/team/first-member", "/fr/team/first-member", "/en/team/a"])(
|
|
20
|
+
"is true for the profile path %s",
|
|
21
|
+
(pathname) => {
|
|
22
|
+
expect(isTeamProfilePath(pathname, LOCALES)).toBe(true);
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
"/team",
|
|
28
|
+
"/team/",
|
|
29
|
+
"/fr/team",
|
|
30
|
+
"/team/a/b",
|
|
31
|
+
"/teams/x",
|
|
32
|
+
"/about",
|
|
33
|
+
"/",
|
|
34
|
+
"",
|
|
35
|
+
])("is false for %s", (pathname) => {
|
|
36
|
+
expect(isTeamProfilePath(pathname, LOCALES)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats a prefix that is not an enabled locale as the first segment", () => {
|
|
40
|
+
expect(isTeamProfilePath("/xx/team/a", ["en"])).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("answers false for an absent pathname", () => {
|
|
44
|
+
expect(isTeamProfilePath(null, LOCALES)).toBe(false);
|
|
45
|
+
expect(isTeamProfilePath(undefined, LOCALES)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("backLinkHref", () => {
|
|
50
|
+
it("names the people-slide anchor", () => {
|
|
51
|
+
expect(PEOPLE_SLIDE_ANCHOR).toBe("slide-people");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns /#slide-people for a profile on a deck home", () => {
|
|
55
|
+
expect(
|
|
56
|
+
backLinkHref({
|
|
57
|
+
pathname: "/team/first-member",
|
|
58
|
+
homePath: "/",
|
|
59
|
+
home: "deck",
|
|
60
|
+
locales: LOCALES,
|
|
61
|
+
}),
|
|
62
|
+
).toBe("/#slide-people");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps the /fr prefix on a non-default locale", () => {
|
|
66
|
+
expect(
|
|
67
|
+
backLinkHref({
|
|
68
|
+
pathname: "/fr/team/first-member",
|
|
69
|
+
homePath: "/fr",
|
|
70
|
+
home: "deck",
|
|
71
|
+
locales: LOCALES,
|
|
72
|
+
}),
|
|
73
|
+
).toBe("/fr#slide-people");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each(["/team", "/team/a/b", "/about", "/teams/x", null, undefined])(
|
|
77
|
+
"returns the home for %s on a deck home",
|
|
78
|
+
(pathname) => {
|
|
79
|
+
expect(
|
|
80
|
+
backLinkHref({ pathname, homePath: "/", home: "deck", locales: LOCALES }),
|
|
81
|
+
).toBe("/");
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it("returns the home for a profile when the home is not a deck", () => {
|
|
86
|
+
expect(
|
|
87
|
+
backLinkHref({
|
|
88
|
+
pathname: "/team/first-member",
|
|
89
|
+
homePath: "/",
|
|
90
|
+
home: "default",
|
|
91
|
+
locales: LOCALES,
|
|
92
|
+
}),
|
|
93
|
+
).toBe("/");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// The same-origin relay (#7644): the site serves the root-relative /v1 paths
|
|
5
|
+
// its own browser forms post to and forwards each, server-side, to the hub.
|
|
6
|
+
//
|
|
7
|
+
// Drives `handleRelay` with real `Request` objects through the real
|
|
8
|
+
// `relayToApi` (jest maps @working-theory/api-client to source), with the
|
|
9
|
+
// global `fetch` — the hub — stubbed.
|
|
10
|
+
//
|
|
11
|
+
// Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
|
|
12
|
+
|
|
13
|
+
import { handleRelay, RELAY_ROUTES } from "../relay";
|
|
14
|
+
|
|
15
|
+
const HUB = "https://hub.example";
|
|
16
|
+
const SITE = "https://spoke.example";
|
|
17
|
+
const K1 = "K1";
|
|
18
|
+
const FORWARD_KEY = "FWD";
|
|
19
|
+
const BODY = '{"siteId":"x","email":"a@b.example"}';
|
|
20
|
+
const QUERY = "?calLink=acme/intro&start=2026-10-05";
|
|
21
|
+
|
|
22
|
+
const ENV_KEYS = [
|
|
23
|
+
"AILK_API_URL",
|
|
24
|
+
"NEXT_PUBLIC_AILK_API_URL",
|
|
25
|
+
"NEXT_PUBLIC_API_URL",
|
|
26
|
+
"AILK_SPOKE_KEY",
|
|
27
|
+
"AILK_VISITOR_FORWARD_KEY",
|
|
28
|
+
] as const;
|
|
29
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
30
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function hubAnswer(
|
|
34
|
+
status = 201,
|
|
35
|
+
body = '{"id":"lead_1","persisted":"db"}',
|
|
36
|
+
headers: Record<string, string> = { "content-type": "application/json" },
|
|
37
|
+
): Response {
|
|
38
|
+
return new Response(body, { status, headers });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fetchMock = jest.fn(async (_url: string, _init: RequestInit) =>
|
|
42
|
+
hubAnswer(),
|
|
43
|
+
);
|
|
44
|
+
const priorFetch = global.fetch;
|
|
45
|
+
|
|
46
|
+
/** The `(url, init)` of the one hub call. */
|
|
47
|
+
function sent(): { url: string; init: RequestInit; headers: Headers } {
|
|
48
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
const call = fetchMock.mock.calls[0];
|
|
50
|
+
if (!call) throw new Error("the hub was not called");
|
|
51
|
+
const [url, init] = call;
|
|
52
|
+
return { url, init, headers: new Headers(init.headers) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SAME_ORIGIN = {
|
|
56
|
+
origin: SITE,
|
|
57
|
+
"sec-fetch-site": "same-origin",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A request to the relay at `path`. A same-origin browser POST by default:
|
|
62
|
+
* `host: spoke.example`, its own `Origin`, `Sec-Fetch-Site: same-origin`
|
|
63
|
+
* and a JSON body. `headers` overrides; a `null` value removes the header.
|
|
64
|
+
*/
|
|
65
|
+
function relayRequest(
|
|
66
|
+
path: string,
|
|
67
|
+
{
|
|
68
|
+
method = "POST",
|
|
69
|
+
headers = {},
|
|
70
|
+
body = BODY,
|
|
71
|
+
}: {
|
|
72
|
+
method?: string;
|
|
73
|
+
headers?: Record<string, string | null>;
|
|
74
|
+
body?: string;
|
|
75
|
+
} = {},
|
|
76
|
+
): Request {
|
|
77
|
+
const merged: Record<string, string | null> = {
|
|
78
|
+
host: "spoke.example",
|
|
79
|
+
...SAME_ORIGIN,
|
|
80
|
+
...(method === "POST" ? { "content-type": "application/json" } : {}),
|
|
81
|
+
...headers,
|
|
82
|
+
};
|
|
83
|
+
const init: RequestInit = {
|
|
84
|
+
method,
|
|
85
|
+
headers: Object.fromEntries(
|
|
86
|
+
Object.entries(merged).filter(
|
|
87
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
if (method === "POST") init.body = body;
|
|
92
|
+
return new Request(`${SITE}${path}`, init);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function errorCode(res: Response): Promise<string | undefined> {
|
|
96
|
+
const json = (await res.json()) as { ok?: boolean; error?: { code?: string } };
|
|
97
|
+
expect(json.ok).toBe(false);
|
|
98
|
+
return json.error?.code;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ENTRIES: ReadonlyArray<{ method: "GET" | "POST"; path: string }> = [
|
|
102
|
+
{ method: "POST", path: "/v1/leads/submit" },
|
|
103
|
+
{ method: "POST", path: "/v1/leads/subscribe" },
|
|
104
|
+
{ method: "POST", path: "/v1/leads/callback" },
|
|
105
|
+
{ method: "GET", path: "/v1/schedule/availability" },
|
|
106
|
+
{ method: "POST", path: "/v1/schedule/book" },
|
|
107
|
+
{ method: "POST", path: "/v1/commerce/invoices" },
|
|
108
|
+
];
|
|
109
|
+
const VISITOR_ENTRIES = ENTRIES.filter(
|
|
110
|
+
(e) => e.path !== "/v1/commerce/invoices",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** The relay request for one D2 entry, with the GET's query string. */
|
|
114
|
+
function entryRequest(
|
|
115
|
+
entry: { method: string; path: string },
|
|
116
|
+
headers: Record<string, string | null> = {},
|
|
117
|
+
): Request {
|
|
118
|
+
return relayRequest(
|
|
119
|
+
entry.method === "GET" ? `${entry.path}${QUERY}` : entry.path,
|
|
120
|
+
{ method: entry.method, headers },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let errorSpy: jest.SpyInstance;
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
fetchMock.mockClear();
|
|
128
|
+
fetchMock.mockImplementation(async () => hubAnswer());
|
|
129
|
+
global.fetch = fetchMock as jest.Mock;
|
|
130
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
131
|
+
process.env.AILK_API_URL = HUB;
|
|
132
|
+
process.env.AILK_SPOKE_KEY = K1;
|
|
133
|
+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
errorSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(() => {
|
|
141
|
+
global.fetch = priorFetch;
|
|
142
|
+
for (const [name, value] of priorEnv) {
|
|
143
|
+
if (value === undefined) delete process.env[name];
|
|
144
|
+
else process.env[name] = value;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("AC-1 — forwarding, one entry each", () => {
|
|
149
|
+
it.each(ENTRIES)(
|
|
150
|
+
"$method $path is forwarded once, unchanged, and its answer passed back",
|
|
151
|
+
async (entry) => {
|
|
152
|
+
const res = await handleRelay(entryRequest(entry));
|
|
153
|
+
|
|
154
|
+
const { url, init } = sent();
|
|
155
|
+
expect(url).toBe(
|
|
156
|
+
`${HUB}${entry.path}${entry.method === "GET" ? QUERY : ""}`,
|
|
157
|
+
);
|
|
158
|
+
expect(init.method).toBe(entry.method);
|
|
159
|
+
if (entry.method === "POST") expect(init.body).toBe(BODY);
|
|
160
|
+
else expect(init.body).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
expect(res.status).toBe(201);
|
|
163
|
+
expect(await res.text()).toBe('{"id":"lead_1","persisted":"db"}');
|
|
164
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
165
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
it("forwards the body byte-for-byte, never re-serialised", async () => {
|
|
170
|
+
const body = '{ "b":1,\n "a" : [2, 1] }';
|
|
171
|
+
await handleRelay(relayRequest("/v1/leads/submit", { body }));
|
|
172
|
+
expect(sent().init.body).toBe(body);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("drops a query string on a POST entry", async () => {
|
|
176
|
+
await handleRelay(relayRequest("/v1/leads/submit?x=1"));
|
|
177
|
+
expect(sent().url).toBe(`${HUB}/v1/leads/submit`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("passes the hub's 429 and retry-after through", async () => {
|
|
181
|
+
fetchMock.mockImplementation(async () =>
|
|
182
|
+
hubAnswer(429, '{"ok":false}', {
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
"retry-after": "30",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
188
|
+
expect(res.status).toBe(429);
|
|
189
|
+
expect(res.headers.get("retry-after")).toBe("30");
|
|
190
|
+
expect(await res.text()).toBe('{"ok":false}');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("answers 502 relay_unreachable when the hub cannot be reached", async () => {
|
|
194
|
+
fetchMock.mockImplementation(async () => {
|
|
195
|
+
throw new TypeError("fetch failed");
|
|
196
|
+
});
|
|
197
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
198
|
+
expect(res.status).toBe(502);
|
|
199
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("AC-2 — only the list", () => {
|
|
204
|
+
it.each([
|
|
205
|
+
["POST", "/v1/workspaces"],
|
|
206
|
+
["GET", "/v1/schedule/meeting-types"],
|
|
207
|
+
["POST", "/v1/flows/x/steps"],
|
|
208
|
+
["GET", "/v1/leads/submit"],
|
|
209
|
+
["POST", "/v1/leads/submit/"],
|
|
210
|
+
["POST", "/v1/leads/submit/extra"],
|
|
211
|
+
["POST", "/v1/Leads/submit"],
|
|
212
|
+
])("%s %s answers 404 not_found and calls no hub", async (method, path) => {
|
|
213
|
+
const res = await handleRelay(relayRequest(path, { method }));
|
|
214
|
+
expect(res.status).toBe(404);
|
|
215
|
+
expect(await errorCode(res)).toBe("not_found");
|
|
216
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("RELAY_ROUTES is exactly the six D2 pairs, and frozen", () => {
|
|
220
|
+
expect(RELAY_ROUTES).toEqual(ENTRIES);
|
|
221
|
+
expect(Object.isFrozen(RELAY_ROUTES)).toBe(true);
|
|
222
|
+
for (const route of RELAY_ROUTES) expect(Object.isFrozen(route)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("AC-3 — the key, and only the site's key", () => {
|
|
227
|
+
it.each(ENTRIES)(
|
|
228
|
+
"$method $path carries the site's key and refuses redirects",
|
|
229
|
+
async (entry) => {
|
|
230
|
+
await handleRelay(entryRequest(entry));
|
|
231
|
+
const { init, headers } = sent();
|
|
232
|
+
expect(headers.get("x-ailk-spoke-key")).toBe(K1);
|
|
233
|
+
expect(init.redirect).toBe("error");
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it.each(ENTRIES)(
|
|
238
|
+
"$method $path without a site key carries none and still works (the single-site path)",
|
|
239
|
+
async (entry) => {
|
|
240
|
+
delete process.env.AILK_SPOKE_KEY;
|
|
241
|
+
const res = await handleRelay(entryRequest(entry));
|
|
242
|
+
const { init, headers } = sent();
|
|
243
|
+
expect(headers.get("x-ailk-spoke-key")).toBeNull();
|
|
244
|
+
expect(init.redirect).toBe("error");
|
|
245
|
+
expect(res.status).toBe(201);
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const CLIENT_SENT = {
|
|
250
|
+
"x-ailk-spoke-key": "EVIL",
|
|
251
|
+
"x-ailk-visitor-forward-key": "EVIL",
|
|
252
|
+
"x-ailk-workspace-id": "wksp_x",
|
|
253
|
+
authorization: "Bearer t",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
it("a client-sent key, forward key, workspace header and authorization never reach the hub", async () => {
|
|
257
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
|
|
258
|
+
const { headers } = sent();
|
|
259
|
+
expect(headers.get("x-ailk-spoke-key")).toBe(K1);
|
|
260
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
|
|
261
|
+
expect(headers.get("x-ailk-workspace-id")).toBeNull();
|
|
262
|
+
expect(headers.get("authorization")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("with no site key, a client-sent key still never reaches the hub", async () => {
|
|
266
|
+
delete process.env.AILK_SPOKE_KEY;
|
|
267
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
|
|
268
|
+
expect(sent().headers.get("x-ailk-spoke-key")).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("AC-4 — the visitor, unchanged; a cross-site post, refused", () => {
|
|
273
|
+
it("forwards x-forwarded-for and user-agent exactly as received", async () => {
|
|
274
|
+
await handleRelay(
|
|
275
|
+
relayRequest("/v1/leads/submit", {
|
|
276
|
+
headers: {
|
|
277
|
+
"x-forwarded-for": "203.0.113.5, 10.0.0.1",
|
|
278
|
+
"user-agent": "UA/1",
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const { headers } = sent();
|
|
283
|
+
expect(headers.get("x-forwarded-for")).toBe("203.0.113.5, 10.0.0.1");
|
|
284
|
+
expect(headers.get("user-agent")).toBe("UA/1");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("sends x-real-ip as x-forwarded-for when that is all it has", async () => {
|
|
288
|
+
await handleRelay(
|
|
289
|
+
relayRequest("/v1/leads/submit", {
|
|
290
|
+
headers: { "x-real-ip": "203.0.113.9" },
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
expect(sent().headers.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sends no x-forwarded-for when it has neither", async () => {
|
|
297
|
+
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
298
|
+
expect(sent().headers.get("x-forwarded-for")).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it.each(VISITOR_ENTRIES)(
|
|
302
|
+
"$method $path carries the keyed visitor IP when the site has a forward key",
|
|
303
|
+
async (entry) => {
|
|
304
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
305
|
+
await handleRelay(
|
|
306
|
+
entryRequest(entry, { "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
|
|
307
|
+
);
|
|
308
|
+
const { headers } = sent();
|
|
309
|
+
expect(headers.get("x-ailk-visitor-ip")).toBe("203.0.113.5");
|
|
310
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBe(FORWARD_KEY);
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
it("POST /v1/commerce/invoices carries no keyed visitor IP", async () => {
|
|
315
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
316
|
+
await handleRelay(
|
|
317
|
+
relayRequest("/v1/commerce/invoices", {
|
|
318
|
+
headers: { "x-forwarded-for": "203.0.113.5" },
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
const { headers } = sent();
|
|
322
|
+
expect(headers.get("x-ailk-visitor-ip")).toBeNull();
|
|
323
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("forwards cookie and origin on /v1/commerce/invoices", async () => {
|
|
327
|
+
await handleRelay(
|
|
328
|
+
relayRequest("/v1/commerce/invoices", { headers: { cookie: "s=1" } }),
|
|
329
|
+
);
|
|
330
|
+
const { headers } = sent();
|
|
331
|
+
expect(headers.get("cookie")).toBe("s=1");
|
|
332
|
+
expect(headers.get("origin")).toBe(SITE);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it.each(VISITOR_ENTRIES)(
|
|
336
|
+
"$method $path forwards neither cookie nor origin",
|
|
337
|
+
async (entry) => {
|
|
338
|
+
await handleRelay(entryRequest(entry, { cookie: "s=1" }));
|
|
339
|
+
const { headers } = sent();
|
|
340
|
+
expect(headers.get("cookie")).toBeNull();
|
|
341
|
+
expect(headers.get("origin")).toBeNull();
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
it.each<[string, Record<string, string | null>]>([
|
|
346
|
+
["a foreign origin", { origin: "https://evil.example" }],
|
|
347
|
+
["origin: null", { origin: "null" }],
|
|
348
|
+
["the same host on another port", { origin: "https://spoke.example:8443" }],
|
|
349
|
+
[
|
|
350
|
+
"sec-fetch-site: cross-site with a same-host origin",
|
|
351
|
+
{ "sec-fetch-site": "cross-site" },
|
|
352
|
+
],
|
|
353
|
+
["sec-fetch-site: same-site", { "sec-fetch-site": "same-site" }],
|
|
354
|
+
])("%s answers 403 cross_site and calls no hub", async (_label, headers) => {
|
|
355
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit", { headers }));
|
|
356
|
+
expect(res.status).toBe(403);
|
|
357
|
+
expect(await errorCode(res)).toBe("cross_site");
|
|
358
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("admits an origin that matches the forwarded host behind a proxy", async () => {
|
|
362
|
+
const res = await handleRelay(
|
|
363
|
+
relayRequest("/v1/leads/submit", {
|
|
364
|
+
headers: {
|
|
365
|
+
host: "internal:3000",
|
|
366
|
+
"x-forwarded-host": "spoke.example, proxy",
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
expect(res.status).toBe(201);
|
|
371
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("admits a JSON POST that carries neither origin nor sec-fetch-site (a non-browser caller)", async () => {
|
|
375
|
+
const res = await handleRelay(
|
|
376
|
+
relayRequest("/v1/leads/submit", {
|
|
377
|
+
headers: { origin: null, "sec-fetch-site": null },
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
expect(res.status).toBe(201);
|
|
381
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("the forwarded host wins over a matching host header", async () => {
|
|
385
|
+
const res = await handleRelay(
|
|
386
|
+
relayRequest("/v1/leads/submit", {
|
|
387
|
+
headers: { host: "spoke.example", "x-forwarded-host": "proxy.example" },
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
expect(res.status).toBe(403);
|
|
391
|
+
expect(await errorCode(res)).toBe("cross_site");
|
|
392
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("a POST that is not JSON answers 415", async () => {
|
|
396
|
+
const res = await handleRelay(
|
|
397
|
+
relayRequest("/v1/leads/submit", {
|
|
398
|
+
headers: { "content-type": "text/plain" },
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
expect(res.status).toBe(415);
|
|
402
|
+
expect(await errorCode(res)).toBe("unsupported_media_type");
|
|
403
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("admits a JSON content-type with parameters, in any case", async () => {
|
|
407
|
+
const res = await handleRelay(
|
|
408
|
+
relayRequest("/v1/leads/submit", {
|
|
409
|
+
headers: { "content-type": "Application/JSON; charset=utf-8" },
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
expect(res.status).toBe(201);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("a body over 1 MiB answers 413", async () => {
|
|
416
|
+
const res = await handleRelay(
|
|
417
|
+
relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_577) }),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(413);
|
|
420
|
+
expect(await errorCode(res)).toBe("payload_too_large");
|
|
421
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("a body of exactly 1 MiB is forwarded", async () => {
|
|
425
|
+
const res = await handleRelay(
|
|
426
|
+
relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_576) }),
|
|
427
|
+
);
|
|
428
|
+
expect(res.status).toBe(201);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("a request already relayed answers 508 relay_loop", async () => {
|
|
432
|
+
const res = await handleRelay(
|
|
433
|
+
relayRequest("/v1/leads/submit", { headers: { "x-ailk-relayed": "1" } }),
|
|
434
|
+
);
|
|
435
|
+
expect(res.status).toBe(508);
|
|
436
|
+
expect(await errorCode(res)).toBe("relay_loop");
|
|
437
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("marks every forwarded call so a self-pointing hub address cannot loop", async () => {
|
|
441
|
+
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
442
|
+
expect(sent().headers.get("x-ailk-relayed")).toBe("1");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("forwards nothing outside its allow-list", async () => {
|
|
446
|
+
await handleRelay(
|
|
447
|
+
relayRequest("/v1/leads/submit", {
|
|
448
|
+
headers: { "x-custom": "1", "accept-language": "en" },
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
const names = [...sent().headers.keys()].sort();
|
|
452
|
+
expect(names).toEqual(["content-type", "x-ailk-relayed", "x-ailk-spoke-key"]);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("hardening — what the site serves, and a missing hub address", () => {
|
|
457
|
+
const mediaType = (res: Response) =>
|
|
458
|
+
res.headers.get("content-type")?.split(";")[0]?.trim();
|
|
459
|
+
|
|
460
|
+
it("passes a JSON content-type through as the hub sent it", async () => {
|
|
461
|
+
fetchMock.mockImplementation(async () =>
|
|
462
|
+
hubAnswer(201, "{}", { "content-type": "application/json; charset=utf-8" }),
|
|
463
|
+
);
|
|
464
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
465
|
+
expect(res.headers.get("content-type")).toBe(
|
|
466
|
+
"application/json; charset=utf-8",
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it.each([
|
|
471
|
+
["an HTML error page", { "content-type": "text/html" }],
|
|
472
|
+
["no content-type at all", {}],
|
|
473
|
+
])("serves %s from the hub as text/plain, never as markup", async (_label, headers) => {
|
|
474
|
+
fetchMock.mockImplementation(async () =>
|
|
475
|
+
hubAnswer(502, "<p>bad gateway</p>", headers),
|
|
476
|
+
);
|
|
477
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
478
|
+
expect(res.status).toBe(502);
|
|
479
|
+
expect(mediaType(res)).toBe("text/plain");
|
|
480
|
+
expect(await res.text()).toBe("<p>bad gateway</p>");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("marks every relayed answer and every refusal nosniff", async () => {
|
|
484
|
+
const relayed = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
485
|
+
const refused = await handleRelay(relayRequest("/v1/nope"));
|
|
486
|
+
expect(relayed.headers.get("x-content-type-options")).toBe("nosniff");
|
|
487
|
+
expect(refused.headers.get("x-content-type-options")).toBe("nosniff");
|
|
488
|
+
expect(mediaType(refused)).toBe("application/json");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("in production", () => {
|
|
492
|
+
const env = process.env as Record<string, string | undefined>;
|
|
493
|
+
const priorNodeEnv = env.NODE_ENV;
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
env.NODE_ENV = "production";
|
|
496
|
+
});
|
|
497
|
+
afterEach(() => {
|
|
498
|
+
env.NODE_ENV = priorNodeEnv;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("answers 502 relay_unreachable and calls nothing when no hub address is set", async () => {
|
|
502
|
+
delete process.env.AILK_API_URL;
|
|
503
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
504
|
+
expect(res.status).toBe(502);
|
|
505
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
506
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("forwards as usual when a hub address is set", async () => {
|
|
510
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
511
|
+
expect(res.status).toBe(201);
|
|
512
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `siteThemeHead` (#7742 D6 / AC-4) — the one helper behind the locale
|
|
3
|
+
* layout's `<style data-ailk="theme-vars">` and `<html>` class and the minimal
|
|
4
|
+
* 404's. Pinned against the expressions the layout computed inline before the
|
|
5
|
+
* extraction, so moving them into the helper changed no byte of the `<style>`
|
|
6
|
+
* text; `layout.test.tsx` and `site-fonts.test.tsx` pin the layout's own
|
|
7
|
+
* emitted text over it.
|
|
8
|
+
*/
|
|
9
|
+
import { applyDesignFonts, themeToCssVarsBothModes } from "@working-theory/theme";
|
|
10
|
+
|
|
11
|
+
import type { SiteFonts } from "../site-fonts";
|
|
12
|
+
|
|
13
|
+
const mockSiteFonts: SiteFonts = { className: "", families: {} };
|
|
14
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: `site-fonts.ts` is a generated data module with no external boundary to mock instead; this is the seam the helper reads.
|
|
15
|
+
jest.mock("../site-fonts", () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
siteFonts: mockSiteFonts,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockLoadSiteBrandTokens = jest.fn();
|
|
21
|
+
const mockLoadSiteShapeCss = jest.fn();
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the helper's one seam onto `_site.mdx`; the overlay is the test's to set.
|
|
23
|
+
jest.mock("../site-theme", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
loadSiteBrandTokens: (...args: unknown[]) => mockLoadSiteBrandTokens(...args),
|
|
26
|
+
loadSiteShapeCss: (...args: unknown[]) => mockLoadSiteShapeCss(...args),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import { siteThemeHead } from "../site-theme-head";
|
|
30
|
+
|
|
31
|
+
/** The locale layout's pre-#7742 inline computation, verbatim. */
|
|
32
|
+
function layoutStyleText(): { css: string; className: string | undefined } {
|
|
33
|
+
const designFonts = applyDesignFonts(mockLoadSiteBrandTokens(), mockSiteFonts);
|
|
34
|
+
return {
|
|
35
|
+
css:
|
|
36
|
+
themeToCssVarsBothModes(designFonts.tokens ? { brand: designFonts.tokens } : {}) +
|
|
37
|
+
mockLoadSiteShapeCss(),
|
|
38
|
+
className: designFonts.className || undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
Object.assign(mockSiteFonts, { className: "", families: {} });
|
|
44
|
+
mockLoadSiteBrandTokens.mockReset().mockReturnValue(undefined);
|
|
45
|
+
mockLoadSiteShapeCss.mockReset().mockReturnValue("");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("siteThemeHead (#7742 D6)", () => {
|
|
49
|
+
it("no theme and no faces: today's CSS and no class", () => {
|
|
50
|
+
const head = siteThemeHead();
|
|
51
|
+
expect(head.css).toBe(themeToCssVarsBothModes({}));
|
|
52
|
+
expect(head.css).toBe(layoutStyleText().css);
|
|
53
|
+
expect(head.fontClassName).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("a brand overlay, shape CSS and design faces: the layout's exact text and class", () => {
|
|
57
|
+
mockLoadSiteBrandTokens.mockReturnValue({ font: { body: "Brand" } });
|
|
58
|
+
mockLoadSiteShapeCss.mockReturnValue(":root{--radius-control:0}");
|
|
59
|
+
Object.assign(mockSiteFonts, {
|
|
60
|
+
className: "f1 f2",
|
|
61
|
+
families: { display: "var(--font-design-display), Georgia, serif" },
|
|
62
|
+
});
|
|
63
|
+
const head = siteThemeHead();
|
|
64
|
+
const expected = layoutStyleText();
|
|
65
|
+
expect(head.css).toBe(expected.css);
|
|
66
|
+
expect(head.css).toContain("--font-body: Brand;");
|
|
67
|
+
expect(head.css.endsWith(":root{--radius-control:0}")).toBe(true);
|
|
68
|
+
expect(head.fontClassName).toBe(expected.className);
|
|
69
|
+
});
|
|
70
|
+
});
|