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,652 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #4666 — the instant AEO score service (D3/D4/D5).
|
|
3
|
-
*
|
|
4
|
-
* Dimension derivation, `lowestTwo` ordering + findings, union states, and
|
|
5
|
-
* fixture loading (AC1, AC3-AC7).
|
|
6
|
-
*/
|
|
7
|
-
import {
|
|
8
|
-
AEO_SCORE_EXCLUDED_READINESS_RULES,
|
|
9
|
-
AEO_SCORE_RULES,
|
|
10
|
-
AEO_DIMENSION_SEVERITY,
|
|
11
|
-
AEO_SCORE_DEFAULT_BUDGET_MS,
|
|
12
|
-
AEO_SCORE_MAX_BUDGET_MS,
|
|
13
|
-
resolveScoreBudgetMs,
|
|
14
|
-
scoreCrawl,
|
|
15
|
-
scoreUrl,
|
|
16
|
-
loadExampleScore,
|
|
17
|
-
} from "../aeo-score";
|
|
18
|
-
import {
|
|
19
|
-
aeoDimensionSchema,
|
|
20
|
-
aeoScoreResultSchema,
|
|
21
|
-
} from "@working-theory/validation";
|
|
22
|
-
import type { CrawlResult } from "@working-theory/aeo/fetch";
|
|
23
|
-
|
|
24
|
-
/** Typed as the platform signature so it assigns to `ScoreUrlDeps.fetchImpl` with no cast. */
|
|
25
|
-
type FetchStub = jest.Mock<
|
|
26
|
-
Promise<Response>,
|
|
27
|
-
[RequestInfo | URL, RequestInit?]
|
|
28
|
-
>;
|
|
29
|
-
|
|
30
|
-
import expectedResult from "../../../../../packages/aeo/__tests__/fixtures/example-score/result.json";
|
|
31
|
-
|
|
32
|
-
function page(
|
|
33
|
-
path: string,
|
|
34
|
-
type: string,
|
|
35
|
-
expectedType: string,
|
|
36
|
-
jsonLd: unknown[],
|
|
37
|
-
): CrawlResult["pages"][number] {
|
|
38
|
-
return {
|
|
39
|
-
path,
|
|
40
|
-
type,
|
|
41
|
-
expectedType,
|
|
42
|
-
url: `https://example.com${path}`,
|
|
43
|
-
jsonLd,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A `fetch`-shaped stub that resolves after `delayMs` with `body`, UNLESS
|
|
49
|
-
* `init.signal` aborts first — mirroring how the real Fetch API honors
|
|
50
|
-
* `AbortController` (the service's own `fetchPage` relies on this to make
|
|
51
|
-
* its per-fetch timeout actually bound the wait; a mock that ignores the
|
|
52
|
-
* signal would never let the budget win the race).
|
|
53
|
-
*/
|
|
54
|
-
function delayedResponse(delayMs: number, body: string) {
|
|
55
|
-
return (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
|
56
|
-
new Promise((resolve, reject) => {
|
|
57
|
-
const timer = setTimeout(() => resolve(new Response(body)), delayMs);
|
|
58
|
-
init?.signal?.addEventListener("abort", () => {
|
|
59
|
-
clearTimeout(timer);
|
|
60
|
-
reject(new DOMException("Aborted", "AbortError"));
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe("AEO_SCORE_RULES", () => {
|
|
66
|
-
it("is exactly the four content rules — never the example/schema-correctness rules (AC4)", () => {
|
|
67
|
-
expect(AEO_SCORE_RULES.map((r) => r.id)).toEqual([
|
|
68
|
-
"schema-coverage",
|
|
69
|
-
"jsonld-validity",
|
|
70
|
-
"entity-markup",
|
|
71
|
-
"faq-howto-incentive",
|
|
72
|
-
]);
|
|
73
|
-
expect(AEO_SCORE_RULES.map((r) => r.id)).not.toContain("always-passes");
|
|
74
|
-
expect(AEO_SCORE_RULES.map((r) => r.id)).not.toContain("always-fails");
|
|
75
|
-
expect(AEO_SCORE_RULES.map((r) => r.id)).not.toContain(
|
|
76
|
-
"schema-correctness",
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("matches the prospect-audit-site severity table verbatim", () => {
|
|
81
|
-
expect(AEO_DIMENSION_SEVERITY).toEqual({
|
|
82
|
-
"schema-coverage": "high",
|
|
83
|
-
"entity-markup": "high",
|
|
84
|
-
"jsonld-validity": "medium",
|
|
85
|
-
"faq-howto-incentive": "low",
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("resolveScoreBudgetMs", () => {
|
|
91
|
-
it("defaults to 6000 when unset", () => {
|
|
92
|
-
expect(resolveScoreBudgetMs(undefined)).toBe(AEO_SCORE_DEFAULT_BUDGET_MS);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("uses the given value when within bounds", () => {
|
|
96
|
-
expect(resolveScoreBudgetMs("8000")).toBe(8000);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("falls back to the default and logs when above the 15000ms cap", () => {
|
|
100
|
-
const warn = jest.fn();
|
|
101
|
-
expect(resolveScoreBudgetMs("20000", { warn })).toBe(
|
|
102
|
-
AEO_SCORE_DEFAULT_BUDGET_MS,
|
|
103
|
-
);
|
|
104
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
105
|
-
expect(warn.mock.calls[0]?.[0]).toContain(String(AEO_SCORE_MAX_BUDGET_MS));
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("falls back silently on an unparseable/non-positive value", () => {
|
|
109
|
-
const warn = jest.fn();
|
|
110
|
-
expect(resolveScoreBudgetMs("not-a-number", { warn })).toBe(
|
|
111
|
-
AEO_SCORE_DEFAULT_BUDGET_MS,
|
|
112
|
-
);
|
|
113
|
-
expect(resolveScoreBudgetMs("-5", { warn })).toBe(
|
|
114
|
-
AEO_SCORE_DEFAULT_BUDGET_MS,
|
|
115
|
-
);
|
|
116
|
-
expect(warn).not.toHaveBeenCalled();
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe("scoreCrawl", () => {
|
|
121
|
-
it("returns unavailable/no_pages_fetched for an empty crawl", () => {
|
|
122
|
-
const crawl: CrawlResult = {
|
|
123
|
-
base: "https://example.com",
|
|
124
|
-
pages: [],
|
|
125
|
-
skipped: [],
|
|
126
|
-
};
|
|
127
|
-
const result = scoreCrawl(crawl);
|
|
128
|
-
expect(result).toEqual({
|
|
129
|
-
status: "unavailable",
|
|
130
|
-
reason: "no_pages_fetched",
|
|
131
|
-
message: "No pages were fetched.",
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("(AC5) lowestTwo is [schema-coverage(high), entity-markup(high)] when every page fails those two and one fails jsonld-validity", () => {
|
|
136
|
-
const crawl: CrawlResult = {
|
|
137
|
-
base: "https://example.com",
|
|
138
|
-
pages: [
|
|
139
|
-
page("/", "homepage", "HomePage", []), // no JSON-LD at all — fails coverage + entity
|
|
140
|
-
page("/about", "about", "AboutPage", [
|
|
141
|
-
{ "@type": "WebPage", name: "About" }, // wrong type, malformed-ish (missing recommended fields is fine)
|
|
142
|
-
]),
|
|
143
|
-
],
|
|
144
|
-
skipped: [],
|
|
145
|
-
};
|
|
146
|
-
const result = scoreCrawl(crawl);
|
|
147
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
148
|
-
const ids = result.lowestTwo.map((d) => d.id);
|
|
149
|
-
expect(ids).toContain("schema-coverage");
|
|
150
|
-
expect(ids).toContain("entity-markup");
|
|
151
|
-
const sc = result.dimensions.find((d) => d.id === "schema-coverage");
|
|
152
|
-
expect(sc?.severity).toBe("high");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("(AC5) faq-howto-incentive scores 0, enters lowestTwo, and carries the fixed opportunity finding when no page has FAQ/HowTo markup", () => {
|
|
156
|
-
const crawl: CrawlResult = {
|
|
157
|
-
base: "https://example.com",
|
|
158
|
-
pages: [
|
|
159
|
-
page("/", "homepage", "HomePage", [
|
|
160
|
-
{ "@type": "HomePage", name: "Home", url: "https://example.com" },
|
|
161
|
-
{ "@type": "Organization", name: "Example Co" },
|
|
162
|
-
]),
|
|
163
|
-
],
|
|
164
|
-
skipped: [],
|
|
165
|
-
};
|
|
166
|
-
const result = scoreCrawl(crawl);
|
|
167
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
168
|
-
const faq = result.dimensions.find((d) => d.id === "faq-howto-incentive");
|
|
169
|
-
expect(faq?.score).toBe(0);
|
|
170
|
-
expect(faq?.finding).toMatch(/no FAQPage or HowTo structured data/);
|
|
171
|
-
expect(faq?.finding).toMatch(/a visibility opportunity, not a defect/);
|
|
172
|
-
expect(result.lowestTwo.map((d) => d.id)).toContain("faq-howto-incentive");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("sets meta.partial true when a page was skipped for budget_exceeded", () => {
|
|
176
|
-
const crawl: CrawlResult = {
|
|
177
|
-
base: "https://example.com",
|
|
178
|
-
pages: [
|
|
179
|
-
page("/", "homepage", "HomePage", [
|
|
180
|
-
{ "@type": "HomePage", name: "Home" },
|
|
181
|
-
]),
|
|
182
|
-
],
|
|
183
|
-
skipped: [
|
|
184
|
-
{
|
|
185
|
-
path: "/about",
|
|
186
|
-
url: "https://example.com/about",
|
|
187
|
-
reason: "budget_exceeded",
|
|
188
|
-
},
|
|
189
|
-
],
|
|
190
|
-
};
|
|
191
|
-
const result = scoreCrawl(crawl);
|
|
192
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
193
|
-
expect(result.meta.partial).toBe(true);
|
|
194
|
-
expect(result.meta.skipped).toHaveLength(1);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("result satisfies aeoScoreResultSchema (AC7)", () => {
|
|
198
|
-
const crawl: CrawlResult = {
|
|
199
|
-
base: "https://example.com",
|
|
200
|
-
pages: [
|
|
201
|
-
page("/", "homepage", "HomePage", [
|
|
202
|
-
{ "@type": "HomePage", name: "Home" },
|
|
203
|
-
]),
|
|
204
|
-
],
|
|
205
|
-
skipped: [],
|
|
206
|
-
};
|
|
207
|
-
const result = scoreCrawl(crawl);
|
|
208
|
-
expect(() => aeoScoreResultSchema.parse(result)).not.toThrow();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// AILK #5110 — mirrors the #4707 fix for the top-level unavailable/
|
|
212
|
-
// ssrf_blocked path: the per-path SSRF-guard rejection detail
|
|
213
|
-
// (assertUrlSafe's raw `reason`, which can disclose a resolved internal
|
|
214
|
-
// host/IP) must never reach a ready response's meta.skipped[].reason.
|
|
215
|
-
describe("meta.skipped[].reason SSRF-guard scrub (#5110)", () => {
|
|
216
|
-
let warnSpy: jest.SpyInstance;
|
|
217
|
-
|
|
218
|
-
beforeEach(() => {
|
|
219
|
-
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
afterEach(() => {
|
|
223
|
-
warnSpy.mockRestore();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it("redacts a raw assertUrlSafe rejection reason to the ssrf_blocked sentinel", () => {
|
|
227
|
-
const crawl: CrawlResult = {
|
|
228
|
-
base: "https://example.com",
|
|
229
|
-
pages: [
|
|
230
|
-
page("/", "homepage", "HomePage", [
|
|
231
|
-
{ "@type": "HomePage", name: "Home" },
|
|
232
|
-
]),
|
|
233
|
-
],
|
|
234
|
-
skipped: [
|
|
235
|
-
{
|
|
236
|
-
path: "/admin",
|
|
237
|
-
url: "http://169.254.169.254/admin",
|
|
238
|
-
reason:
|
|
239
|
-
"Host 169.254.169.254 resolves to a blocked address: 169.254.169.254",
|
|
240
|
-
},
|
|
241
|
-
],
|
|
242
|
-
};
|
|
243
|
-
const result = scoreCrawl(crawl);
|
|
244
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
245
|
-
expect(result.meta.skipped).toHaveLength(1);
|
|
246
|
-
expect(result.meta.skipped[0]?.reason).toBe("ssrf_blocked");
|
|
247
|
-
expect(result.meta.skipped[0]?.reason).not.toMatch(/169\.254\.169\.254/);
|
|
248
|
-
expect(result.meta.skipped[0]?.reason).not.toMatch(
|
|
249
|
-
/resolves to a blocked address/i,
|
|
250
|
-
);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("(AC-2) logs the raw per-path rejection detail server-side before redacting it", () => {
|
|
254
|
-
const crawl: CrawlResult = {
|
|
255
|
-
base: "https://example.com",
|
|
256
|
-
pages: [
|
|
257
|
-
page("/", "homepage", "HomePage", [
|
|
258
|
-
{ "@type": "HomePage", name: "Home" },
|
|
259
|
-
]),
|
|
260
|
-
],
|
|
261
|
-
skipped: [
|
|
262
|
-
{
|
|
263
|
-
path: "/admin",
|
|
264
|
-
url: "http://169.254.169.254/admin",
|
|
265
|
-
reason:
|
|
266
|
-
"Host 169.254.169.254 resolves to a blocked address: 169.254.169.254",
|
|
267
|
-
},
|
|
268
|
-
],
|
|
269
|
-
};
|
|
270
|
-
scoreCrawl(crawl);
|
|
271
|
-
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
272
|
-
const [message, meta] = warnSpy.mock.calls[0] as [
|
|
273
|
-
string,
|
|
274
|
-
Record<string, unknown>,
|
|
275
|
-
];
|
|
276
|
-
expect(message).toMatch(/aeo-score/);
|
|
277
|
-
expect(meta).toMatchObject({
|
|
278
|
-
path: "/admin",
|
|
279
|
-
url: "http://169.254.169.254/admin",
|
|
280
|
-
reason:
|
|
281
|
-
"Host 169.254.169.254 resolves to a blocked address: 169.254.169.254",
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("leaves a non-SSRF skip reason (budget_exceeded, an HTTP failure) unchanged", () => {
|
|
286
|
-
const crawl: CrawlResult = {
|
|
287
|
-
base: "https://example.com",
|
|
288
|
-
pages: [
|
|
289
|
-
page("/", "homepage", "HomePage", [
|
|
290
|
-
{ "@type": "HomePage", name: "Home" },
|
|
291
|
-
]),
|
|
292
|
-
],
|
|
293
|
-
skipped: [
|
|
294
|
-
{
|
|
295
|
-
path: "/about",
|
|
296
|
-
url: "https://example.com/about",
|
|
297
|
-
reason: "budget_exceeded",
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
path: "/blog",
|
|
301
|
-
url: "https://example.com/blog",
|
|
302
|
-
reason: "HTTP 404: https://example.com/blog",
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
};
|
|
306
|
-
const result = scoreCrawl(crawl);
|
|
307
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
308
|
-
expect(result.meta.skipped).toEqual([
|
|
309
|
-
{
|
|
310
|
-
path: "/about",
|
|
311
|
-
url: "https://example.com/about",
|
|
312
|
-
reason: "budget_exceeded",
|
|
313
|
-
},
|
|
314
|
-
{
|
|
315
|
-
path: "/blog",
|
|
316
|
-
url: "https://example.com/blog",
|
|
317
|
-
reason: "HTTP 404: https://example.com/blog",
|
|
318
|
-
},
|
|
319
|
-
]);
|
|
320
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it("end-to-end via scoreUrl: a path blocked mid-crawl (simulated DNS rebinding) never surfaces its raw reason on a ready result", async () => {
|
|
324
|
-
let calls = 0;
|
|
325
|
-
const resolver = async () => {
|
|
326
|
-
calls += 1;
|
|
327
|
-
// Call 1 is scoreUrl's own top-level pre-flight assertUrlSafe check;
|
|
328
|
-
// call 2 is crawlSite's first per-path re-check (whichever path
|
|
329
|
-
// processOne reaches first) — both resolve safely. Every subsequent
|
|
330
|
-
// per-path call — standing in for DNS rebinding mid-crawl —
|
|
331
|
-
// resolves to an internal address, so at least one page succeeds
|
|
332
|
-
// and at least one is SSRF-skipped.
|
|
333
|
-
return calls <= 2 ? ["93.184.216.34"] : ["10.1.2.3"];
|
|
334
|
-
};
|
|
335
|
-
const fetchImpl: FetchStub = jest.fn(
|
|
336
|
-
async () =>
|
|
337
|
-
new Response(
|
|
338
|
-
'<html><script type="application/ld+json">{"@type":"HomePage","name":"Home"}</script></html>',
|
|
339
|
-
),
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
const result = await scoreUrl("https://example.com", {
|
|
343
|
-
fetchImpl,
|
|
344
|
-
resolver,
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
if (result.status !== "ready") {
|
|
348
|
-
throw new Error(`expected ready, got ${JSON.stringify(result)}`);
|
|
349
|
-
}
|
|
350
|
-
expect(result.meta.skipped.length).toBeGreaterThan(0);
|
|
351
|
-
for (const skip of result.meta.skipped) {
|
|
352
|
-
expect(skip.reason).toBe("ssrf_blocked");
|
|
353
|
-
expect(skip.reason).not.toMatch(/10\.1\.2\.3/);
|
|
354
|
-
expect(skip.reason).not.toMatch(/resolves to a blocked address/i);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
describe("aeoScoreResultSchema (AC7)", () => {
|
|
361
|
-
it("parses ready, computing, and each unavailable reason", () => {
|
|
362
|
-
expect(() =>
|
|
363
|
-
aeoScoreResultSchema.parse({
|
|
364
|
-
status: "ready",
|
|
365
|
-
score: 50,
|
|
366
|
-
dimensions: [
|
|
367
|
-
{
|
|
368
|
-
id: "schema-coverage",
|
|
369
|
-
label: "x",
|
|
370
|
-
weight: 10,
|
|
371
|
-
score: 50,
|
|
372
|
-
severity: "high",
|
|
373
|
-
finding: null,
|
|
374
|
-
pagesPassing: 1,
|
|
375
|
-
pagesTotal: 2,
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
id: "jsonld-validity",
|
|
379
|
-
label: "x",
|
|
380
|
-
weight: 10,
|
|
381
|
-
score: 50,
|
|
382
|
-
severity: "medium",
|
|
383
|
-
finding: null,
|
|
384
|
-
pagesPassing: 1,
|
|
385
|
-
pagesTotal: 2,
|
|
386
|
-
},
|
|
387
|
-
{
|
|
388
|
-
id: "entity-markup",
|
|
389
|
-
label: "x",
|
|
390
|
-
weight: 1,
|
|
391
|
-
score: 50,
|
|
392
|
-
severity: "high",
|
|
393
|
-
finding: null,
|
|
394
|
-
pagesPassing: 1,
|
|
395
|
-
pagesTotal: 2,
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
id: "faq-howto-incentive",
|
|
399
|
-
label: "x",
|
|
400
|
-
weight: 0,
|
|
401
|
-
score: 50,
|
|
402
|
-
severity: "low",
|
|
403
|
-
finding: null,
|
|
404
|
-
pagesPassing: 1,
|
|
405
|
-
pagesTotal: 2,
|
|
406
|
-
},
|
|
407
|
-
],
|
|
408
|
-
lowestTwo: [
|
|
409
|
-
{
|
|
410
|
-
id: "schema-coverage",
|
|
411
|
-
label: "x",
|
|
412
|
-
weight: 10,
|
|
413
|
-
score: 50,
|
|
414
|
-
severity: "high",
|
|
415
|
-
finding: null,
|
|
416
|
-
pagesPassing: 1,
|
|
417
|
-
pagesTotal: 2,
|
|
418
|
-
},
|
|
419
|
-
{
|
|
420
|
-
id: "entity-markup",
|
|
421
|
-
label: "x",
|
|
422
|
-
weight: 1,
|
|
423
|
-
score: 50,
|
|
424
|
-
severity: "high",
|
|
425
|
-
finding: null,
|
|
426
|
-
pagesPassing: 1,
|
|
427
|
-
pagesTotal: 2,
|
|
428
|
-
},
|
|
429
|
-
],
|
|
430
|
-
meta: {
|
|
431
|
-
auditedAt: new Date().toISOString(),
|
|
432
|
-
pagesAudited: 2,
|
|
433
|
-
partial: false,
|
|
434
|
-
skipped: [],
|
|
435
|
-
schemaVersion: "1.0.0",
|
|
436
|
-
registryVersion: "1.0.0",
|
|
437
|
-
},
|
|
438
|
-
}),
|
|
439
|
-
).not.toThrow();
|
|
440
|
-
|
|
441
|
-
expect(() =>
|
|
442
|
-
aeoScoreResultSchema.parse({ status: "computing" }),
|
|
443
|
-
).not.toThrow();
|
|
444
|
-
|
|
445
|
-
for (const reason of [
|
|
446
|
-
"ssrf_blocked",
|
|
447
|
-
"no_pages_fetched",
|
|
448
|
-
"crawl_failed",
|
|
449
|
-
] as const) {
|
|
450
|
-
expect(() =>
|
|
451
|
-
aeoScoreResultSchema.parse({
|
|
452
|
-
status: "unavailable",
|
|
453
|
-
reason,
|
|
454
|
-
message: "x",
|
|
455
|
-
}),
|
|
456
|
-
).not.toThrow();
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it("rejects a status that mixes an unavailable discriminant with a ready-only field name absent from the schema", () => {
|
|
461
|
-
const parsed = aeoScoreResultSchema.safeParse({
|
|
462
|
-
status: "unavailable",
|
|
463
|
-
// missing required `reason` / `message` — the mixed/malformed shape.
|
|
464
|
-
score: 50,
|
|
465
|
-
});
|
|
466
|
-
expect(parsed.success).toBe(false);
|
|
467
|
-
});
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
describe("scoreUrl", () => {
|
|
471
|
-
it("returns unavailable/ssrf_blocked without ever calling fetch, for a blocked host", async () => {
|
|
472
|
-
const fetchImpl: FetchStub = jest.fn();
|
|
473
|
-
const result = await scoreUrl("http://127.0.0.1/", { fetchImpl });
|
|
474
|
-
expect(result).toEqual({
|
|
475
|
-
status: "unavailable",
|
|
476
|
-
reason: "ssrf_blocked",
|
|
477
|
-
message: expect.stringMatching(/Blocked host/i),
|
|
478
|
-
});
|
|
479
|
-
expect(fetchImpl).not.toHaveBeenCalled();
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
it("(AC1) issues the five default paths concurrently — a 2s-delay fetchImpl completes well under 10s", async () => {
|
|
483
|
-
const fetchImpl: FetchStub = jest.fn(async () => {
|
|
484
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
485
|
-
return new Response(
|
|
486
|
-
'<html><script type="application/ld+json">{"@type":"HomePage","name":"x"}</script></html>',
|
|
487
|
-
);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
const t0 = Date.now();
|
|
491
|
-
const result = await scoreUrl("https://example.com", {
|
|
492
|
-
fetchImpl,
|
|
493
|
-
resolver: async () => ["93.184.216.34"],
|
|
494
|
-
});
|
|
495
|
-
const elapsed = Date.now() - t0;
|
|
496
|
-
|
|
497
|
-
expect(elapsed).toBeLessThan(5000);
|
|
498
|
-
expect(result.status).toBe("ready");
|
|
499
|
-
}, 10_000);
|
|
500
|
-
|
|
501
|
-
it("(AC1) with budgetMs 500 and a 2s-delay fetchImpl, returns quickly as unavailable/no_pages_fetched", async () => {
|
|
502
|
-
const fetchImpl: FetchStub = jest.fn(
|
|
503
|
-
delayedResponse(2000, "<html></html>"),
|
|
504
|
-
);
|
|
505
|
-
|
|
506
|
-
const t0 = Date.now();
|
|
507
|
-
const result = await scoreUrl("https://example.com", {
|
|
508
|
-
fetchImpl,
|
|
509
|
-
resolver: async () => ["93.184.216.34"],
|
|
510
|
-
budgetMs: 500,
|
|
511
|
-
});
|
|
512
|
-
const elapsed = Date.now() - t0;
|
|
513
|
-
|
|
514
|
-
// Spec AC1: "returns inside ~600 ms" — tight enough to prove the abort
|
|
515
|
-
// actually fired at the 500ms budget rather than merely finishing
|
|
516
|
-
// before the 2000ms fetchImpl delay or the 10s jest timeout.
|
|
517
|
-
expect(elapsed).toBeLessThan(1000);
|
|
518
|
-
expect(result).toEqual({
|
|
519
|
-
status: "unavailable",
|
|
520
|
-
reason: "no_pages_fetched",
|
|
521
|
-
message: expect.any(String),
|
|
522
|
-
});
|
|
523
|
-
}, 10_000);
|
|
524
|
-
|
|
525
|
-
it("(AC1) one fast + four slow pages returns ready with meta.partial true and budget_exceeded skips", async () => {
|
|
526
|
-
const homeResponse = delayedResponse(
|
|
527
|
-
0,
|
|
528
|
-
'<html><script type="application/ld+json">{"@type":"HomePage","name":"x"}</script></html>',
|
|
529
|
-
);
|
|
530
|
-
const slowResponse = delayedResponse(5000, "<html></html>");
|
|
531
|
-
const fetchImpl: FetchStub = jest.fn(
|
|
532
|
-
(input: RequestInfo | URL, init?: RequestInit) => {
|
|
533
|
-
const isHome = String(input).endsWith("example.com/");
|
|
534
|
-
return isHome ? homeResponse(input, init) : slowResponse(input, init);
|
|
535
|
-
},
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
const result = await scoreUrl("https://example.com", {
|
|
539
|
-
fetchImpl,
|
|
540
|
-
resolver: async () => ["93.184.216.34"],
|
|
541
|
-
budgetMs: 800,
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
if (result.status !== "ready")
|
|
545
|
-
throw new Error(`expected ready, got ${JSON.stringify(result)}`);
|
|
546
|
-
expect(result.meta.partial).toBe(true);
|
|
547
|
-
// Spec AC1: one fast + FOUR slow pages — all four abort at the budget.
|
|
548
|
-
expect(
|
|
549
|
-
result.meta.skipped.filter((s) => s.reason === "budget_exceeded").length,
|
|
550
|
-
).toBe(4);
|
|
551
|
-
expect(result.meta.pagesAudited).toBe(1);
|
|
552
|
-
}, 10_000);
|
|
553
|
-
|
|
554
|
-
it("returns a well-formed unavailable result (never a rejected promise) when a per-page fetch throws", async () => {
|
|
555
|
-
const fetchImpl: FetchStub = jest.fn(() => {
|
|
556
|
-
throw new Error("boom");
|
|
557
|
-
});
|
|
558
|
-
const result = await scoreUrl("https://example.com", {
|
|
559
|
-
fetchImpl,
|
|
560
|
-
resolver: async () => ["93.184.216.34"],
|
|
561
|
-
});
|
|
562
|
-
// A per-page fetch throw is caught INSIDE crawlSite as a skipped page, not
|
|
563
|
-
// a crawlSite-level throw — so this surfaces as no_pages_fetched.
|
|
564
|
-
expect(result).toEqual({
|
|
565
|
-
status: "unavailable",
|
|
566
|
-
reason: "no_pages_fetched",
|
|
567
|
-
message: expect.any(String),
|
|
568
|
-
});
|
|
569
|
-
});
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
describe("loadExampleScore (AC6, D6)", () => {
|
|
573
|
-
it("returns a ready result with meta.example true, sourceUrl set, and a non-empty annotation on every dimension — no fs on the request path", () => {
|
|
574
|
-
const result = loadExampleScore();
|
|
575
|
-
expect(result.status).toBe("ready");
|
|
576
|
-
if (result.status !== "ready") return;
|
|
577
|
-
expect(result.meta.example).toBe(true);
|
|
578
|
-
expect(result.meta.sourceUrl).toBe("https://ailaunchkit.ai");
|
|
579
|
-
for (const d of result.dimensions) {
|
|
580
|
-
expect(typeof d.annotation).toBe("string");
|
|
581
|
-
expect((d.annotation ?? "").length).toBeGreaterThan(0);
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
it("(acceptance-review finding) surfaces meta.illustrative on the wire — today's hand-authored fixture is not a genuine crawl of sourceUrl", () => {
|
|
586
|
-
const result = loadExampleScore();
|
|
587
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
588
|
-
// Sourced from EXAMPLE_SCORE_META, not hardcoded — flips to false the
|
|
589
|
-
// moment the fixture is regenerated from a real crawl.
|
|
590
|
-
expect(result.meta.illustrative).toBe(true);
|
|
591
|
-
expect(() => aeoScoreResultSchema.parse(result)).not.toThrow();
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it("deep-equals the committed fixture (minus auditedAt, plus annotations/meta) — proves the service's rule set never diverges from the generator's", () => {
|
|
595
|
-
const result = loadExampleScore();
|
|
596
|
-
if (result.status !== "ready") throw new Error("expected ready");
|
|
597
|
-
const { auditedAt: _auditedAt, ...metaWithoutAuditedAt } = result.meta;
|
|
598
|
-
void _auditedAt;
|
|
599
|
-
const stripAnnotation = (d: (typeof result.dimensions)[number]) => {
|
|
600
|
-
const { annotation: _annotation, ...rest } = d;
|
|
601
|
-
void _annotation;
|
|
602
|
-
return rest;
|
|
603
|
-
};
|
|
604
|
-
expect({
|
|
605
|
-
status: result.status,
|
|
606
|
-
score: result.score,
|
|
607
|
-
dimensions: result.dimensions.map(stripAnnotation),
|
|
608
|
-
lowestTwo: result.lowestTwo.map(stripAnnotation),
|
|
609
|
-
meta: {
|
|
610
|
-
pagesAudited: metaWithoutAuditedAt.pagesAudited,
|
|
611
|
-
partial: metaWithoutAuditedAt.partial,
|
|
612
|
-
skipped: metaWithoutAuditedAt.skipped,
|
|
613
|
-
schemaVersion: metaWithoutAuditedAt.schemaVersion,
|
|
614
|
-
registryVersion: metaWithoutAuditedAt.registryVersion,
|
|
615
|
-
},
|
|
616
|
-
}).toEqual(expectedResult);
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* #5949 D6/FR11 — the readiness rules are deliberately NOT in this service's
|
|
622
|
-
* rule set. They grade frontmatter and site-set facts, and `buildAuditPayload`
|
|
623
|
-
* produces no frontmatter, so on a crawl input all three would pass vacuously:
|
|
624
|
-
* three always-100 dimensions in a closed four-member public enum.
|
|
625
|
-
*
|
|
626
|
-
* `/v1/aeo/score` has no MCP twin (apps/api/CLAUDE.md § API ↔ MCP parity), so
|
|
627
|
-
* the skip is not a parity divergence — and these assertions are the "stated
|
|
628
|
-
* and tested" half AC-3 asks for, failing loudly if either side drifts.
|
|
629
|
-
*/
|
|
630
|
-
describe("readiness-rule exclusion (#5949 D6)", () => {
|
|
631
|
-
it("AEO_SCORE_RULES carries none of the excluded readiness rules", () => {
|
|
632
|
-
const ids = AEO_SCORE_RULES.map((r) => r.id);
|
|
633
|
-
for (const excluded of AEO_SCORE_EXCLUDED_READINESS_RULES) {
|
|
634
|
-
expect(ids).not.toContain(excluded);
|
|
635
|
-
}
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it("the public dimension enum is still exactly the four content rules", () => {
|
|
639
|
-
expect([...aeoDimensionSchema.shape.id.options].sort()).toEqual([
|
|
640
|
-
"entity-markup",
|
|
641
|
-
"faq-howto-incentive",
|
|
642
|
-
"jsonld-validity",
|
|
643
|
-
"schema-coverage",
|
|
644
|
-
]);
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
it("AEO_SCORE_RULES' ids are exactly the dimension enum's options", () => {
|
|
648
|
-
expect(AEO_SCORE_RULES.map((r) => r.id).sort()).toEqual(
|
|
649
|
-
[...aeoDimensionSchema.shape.id.options].sort(),
|
|
650
|
-
);
|
|
651
|
-
});
|
|
652
|
-
});
|