@agent-native/creative-context 0.5.11 → 0.6.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/dist/actions/list-context-connections.js +1 -1
- package/dist/client/CreativeContextPanel.js +1 -1
- package/dist/connectors/index.d.ts +1 -0
- package/dist/connectors/index.d.ts.map +1 -1
- package/dist/connectors/index.js +1 -0
- package/dist/connectors/index.js.map +1 -1
- package/dist/connectors/rendered-design.d.ts +68 -0
- package/dist/connectors/rendered-design.d.ts.map +1 -0
- package/dist/connectors/rendered-design.js +362 -0
- package/dist/connectors/rendered-design.js.map +1 -0
- package/dist/connectors/rendered-page.d.ts +19 -1
- package/dist/connectors/rendered-page.d.ts.map +1 -1
- package/dist/connectors/rendered-page.js +385 -129
- package/dist/connectors/rendered-page.js.map +1 -1
- package/dist/jobs/server-worker.d.ts.map +1 -1
- package/dist/jobs/server-worker.js +5 -3
- package/dist/jobs/server-worker.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +5 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/list-context-connections.ts +1 -1
- package/src/client/CreativeContextPanel.tsx +1 -1
- package/src/connectors/index.ts +9 -0
- package/src/connectors/rendered-design.spec.ts +171 -0
- package/src/connectors/rendered-design.ts +498 -0
- package/src/connectors/rendered-page.spec.ts +94 -0
- package/src/connectors/rendered-page.ts +494 -174
- package/src/connectors/website.spec.ts +27 -71
- package/src/jobs/server-worker.spec.ts +32 -10
- package/src/jobs/server-worker.ts +5 -2
- package/src/server/index.spec.ts +15 -0
- package/src/server/index.ts +13 -1
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BrandKitData,
|
|
3
|
+
BrandKitToken,
|
|
4
|
+
BrandKitTokenType,
|
|
5
|
+
} from "@agent-native/core/brand-kit";
|
|
6
|
+
import {
|
|
7
|
+
classifyBrandKitToken,
|
|
8
|
+
friendlyTokenName,
|
|
9
|
+
normalizeBrandWebsiteUrl,
|
|
10
|
+
} from "@agent-native/core/brand-kit";
|
|
11
|
+
import type {
|
|
12
|
+
WebsiteDesignTokens,
|
|
13
|
+
WebsiteExtraction,
|
|
14
|
+
} from "@agent-native/core/ingestion";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
LayeredRenderedPageProvider,
|
|
18
|
+
type RenderedPageProvider,
|
|
19
|
+
type RenderedPageRequest,
|
|
20
|
+
type RenderedPageResult,
|
|
21
|
+
} from "./rendered-page.js";
|
|
22
|
+
|
|
23
|
+
const MAX_DESIGN_MD_CHARS = 20_000;
|
|
24
|
+
const MAX_TOKENS = 500;
|
|
25
|
+
const FALLBACK_COLORS = {
|
|
26
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
27
|
+
primary: "#111827",
|
|
28
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
29
|
+
secondary: "#374151",
|
|
30
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
31
|
+
accent: "#2563EB",
|
|
32
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
33
|
+
background: "#FFFFFF",
|
|
34
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
35
|
+
surface: "#F9FAFB",
|
|
36
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
37
|
+
text: "#111827",
|
|
38
|
+
// guard:allow-raw-color - fallback design-token data, not rendered UI color
|
|
39
|
+
textMuted: "#6B7280",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export type RenderedDesignExtractionStatus = "complete" | "partial" | "failed";
|
|
43
|
+
|
|
44
|
+
export interface RenderedDesignExtraction {
|
|
45
|
+
status: RenderedDesignExtractionStatus;
|
|
46
|
+
url: string;
|
|
47
|
+
finalUrl?: string;
|
|
48
|
+
title?: string;
|
|
49
|
+
rendered: boolean;
|
|
50
|
+
method?: RenderedPageResult["method"];
|
|
51
|
+
confidence?: number;
|
|
52
|
+
designTokens?: WebsiteDesignTokens;
|
|
53
|
+
designMd?: string;
|
|
54
|
+
brandKit?: BrandKitData;
|
|
55
|
+
/** Back-compat projections for callers that consumed the old URL action. */
|
|
56
|
+
pageTitle?: string;
|
|
57
|
+
cssCustomProperties?: Record<string, string>;
|
|
58
|
+
colors?: string[];
|
|
59
|
+
fonts?: string[];
|
|
60
|
+
fontFaces?: Array<{ family: string; weight?: string }>;
|
|
61
|
+
googleFonts?: string[];
|
|
62
|
+
stylesheetUrls?: string[];
|
|
63
|
+
assets?: RenderedPageResult["extraction"]["assets"];
|
|
64
|
+
screenshotEvidence?: Array<{
|
|
65
|
+
viewport: "desktop" | "mobile";
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
bytes: number;
|
|
69
|
+
}>;
|
|
70
|
+
warnings: string[];
|
|
71
|
+
diagnostics: string[];
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ExtractRenderedDesignOptions extends Pick<
|
|
76
|
+
RenderedPageRequest,
|
|
77
|
+
"timeoutMs" | "preferHosted"
|
|
78
|
+
> {
|
|
79
|
+
provider?: RenderedPageProvider;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract a bounded visual language from a public website. Browser rendering
|
|
84
|
+
* is deliberately delegated to the layered provider so this works with
|
|
85
|
+
* Builder Browser, local Playwright, an approved attached browser, or the
|
|
86
|
+
* existing SSRF-safe static fallback without changing app code.
|
|
87
|
+
*/
|
|
88
|
+
export async function extractRenderedDesignSystemFromUrl(
|
|
89
|
+
websiteUrl: string,
|
|
90
|
+
options: ExtractRenderedDesignOptions = {},
|
|
91
|
+
): Promise<RenderedDesignExtraction> {
|
|
92
|
+
let url: string;
|
|
93
|
+
try {
|
|
94
|
+
url = normalizeBrandWebsiteUrl(websiteUrl);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return failedExtraction(websiteUrl, error);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const provider = options.provider ?? new LayeredRenderedPageProvider();
|
|
100
|
+
let page: RenderedPageResult;
|
|
101
|
+
try {
|
|
102
|
+
page = await provider.render({
|
|
103
|
+
url,
|
|
104
|
+
timeoutMs: options.timeoutMs,
|
|
105
|
+
preferHosted: options.preferHosted,
|
|
106
|
+
waitUntil: "load",
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return failedExtraction(url, error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const designTokens = page.extraction.designTokens;
|
|
113
|
+
const designMd = buildDesignMarkdown({
|
|
114
|
+
url,
|
|
115
|
+
finalUrl: page.finalUrl,
|
|
116
|
+
title: page.title,
|
|
117
|
+
rendered: page.rendered,
|
|
118
|
+
method: page.method,
|
|
119
|
+
confidence: page.confidence,
|
|
120
|
+
designTokens,
|
|
121
|
+
warnings: page.warnings,
|
|
122
|
+
});
|
|
123
|
+
const status: RenderedDesignExtractionStatus = page.rendered
|
|
124
|
+
? page.warnings.length > 0 || page.screenshots.length < 2
|
|
125
|
+
? "partial"
|
|
126
|
+
: "complete"
|
|
127
|
+
: "partial";
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
status,
|
|
131
|
+
url,
|
|
132
|
+
finalUrl: page.finalUrl,
|
|
133
|
+
title: page.title,
|
|
134
|
+
rendered: page.rendered,
|
|
135
|
+
method: page.method,
|
|
136
|
+
confidence: page.confidence,
|
|
137
|
+
designTokens,
|
|
138
|
+
designMd,
|
|
139
|
+
brandKit: brandKitDataFromExtraction({
|
|
140
|
+
url,
|
|
141
|
+
finalUrl: page.finalUrl,
|
|
142
|
+
title: page.title,
|
|
143
|
+
designTokens,
|
|
144
|
+
designMd,
|
|
145
|
+
assets: page.extraction.assets,
|
|
146
|
+
}),
|
|
147
|
+
pageTitle: page.title,
|
|
148
|
+
cssCustomProperties: designTokens.cssVariables,
|
|
149
|
+
colors: designTokens.colors,
|
|
150
|
+
fonts: uniqueStrings(
|
|
151
|
+
designTokens.typography.map((style) => firstFontFamily(style.family)),
|
|
152
|
+
),
|
|
153
|
+
fontFaces: designTokens.typography.slice(0, 24).map((style) => ({
|
|
154
|
+
family: firstFontFamily(style.family),
|
|
155
|
+
weight: style.weight,
|
|
156
|
+
})),
|
|
157
|
+
googleFonts: uniqueStrings(
|
|
158
|
+
designTokens.typography.map((style) => firstFontFamily(style.family)),
|
|
159
|
+
),
|
|
160
|
+
stylesheetUrls: page.extraction.assets
|
|
161
|
+
.filter((asset) => asset.kind === "stylesheet")
|
|
162
|
+
.map((asset) => asset.url)
|
|
163
|
+
.slice(0, 64),
|
|
164
|
+
assets: page.extraction.assets,
|
|
165
|
+
screenshotEvidence: page.screenshots.map((screenshot) => ({
|
|
166
|
+
viewport: screenshot.viewport,
|
|
167
|
+
width: screenshot.width,
|
|
168
|
+
height: screenshot.height,
|
|
169
|
+
bytes: screenshot.data.byteLength,
|
|
170
|
+
})),
|
|
171
|
+
warnings: page.warnings,
|
|
172
|
+
diagnostics: page.diagnostics,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Convert the shared browser result into the Assets style-brief vocabulary. */
|
|
177
|
+
export function styleBriefFromRenderedDesign(
|
|
178
|
+
extraction: RenderedDesignExtraction,
|
|
179
|
+
): Record<string, unknown> {
|
|
180
|
+
const tokens = extraction.designTokens;
|
|
181
|
+
const typography = tokens?.typography ?? [];
|
|
182
|
+
const componentStyles = tokens?.components ?? [];
|
|
183
|
+
const semanticColors = tokens?.semanticColors ?? {};
|
|
184
|
+
const bodyTypography = componentStyles.find((style) => style.role === "body");
|
|
185
|
+
const headingTypography = componentStyles.find(
|
|
186
|
+
(style) => style.role === "heading",
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
description: extraction.finalUrl
|
|
191
|
+
? `Rendered visual language extracted from ${extraction.finalUrl}.`
|
|
192
|
+
: "Rendered visual language extracted from a website.",
|
|
193
|
+
palette: tokens?.colors?.slice(0, 12) ?? [],
|
|
194
|
+
fontFamilies: uniqueStrings(
|
|
195
|
+
typography.map((style) => firstFontFamily(style.family)),
|
|
196
|
+
),
|
|
197
|
+
fontWeights: uniqueStrings(
|
|
198
|
+
typography
|
|
199
|
+
.map((style) => style.weight)
|
|
200
|
+
.concat(componentStyles.map((style) => style.fontWeight ?? "")),
|
|
201
|
+
),
|
|
202
|
+
typographyPolicy: typographyPolicy(headingTypography, bodyTypography),
|
|
203
|
+
composition: tokens?.layout
|
|
204
|
+
? `Content width ${tokens.layout.contentWidth ?? "fluid"}; page padding ${tokens.layout.pagePadding ?? "not observed"}; section gap ${tokens.layout.sectionGap ?? "not observed"}.`
|
|
205
|
+
: undefined,
|
|
206
|
+
sourceUrl: extraction.finalUrl ?? extraction.url,
|
|
207
|
+
rendered: extraction.rendered,
|
|
208
|
+
designMd: extraction.designMd,
|
|
209
|
+
semanticColors,
|
|
210
|
+
spacing: tokens?.spacing?.slice(0, 24),
|
|
211
|
+
radii: tokens?.radii?.slice(0, 16),
|
|
212
|
+
shadows: tokens?.shadows?.slice(0, 12),
|
|
213
|
+
backgrounds: tokens?.backgrounds?.slice(0, 12),
|
|
214
|
+
componentStyles: componentStyles.slice(0, 12),
|
|
215
|
+
cssVariables: tokens?.cssVariables,
|
|
216
|
+
warnings: extraction.warnings,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function brandKitDataFromExtraction(input: {
|
|
221
|
+
url: string;
|
|
222
|
+
finalUrl?: string;
|
|
223
|
+
title?: string;
|
|
224
|
+
designTokens: WebsiteDesignTokens;
|
|
225
|
+
designMd: string;
|
|
226
|
+
assets?: RenderedPageResult["extraction"]["assets"];
|
|
227
|
+
}): BrandKitData {
|
|
228
|
+
const tokens = input.designTokens;
|
|
229
|
+
const semantic = tokens.semanticColors ?? {};
|
|
230
|
+
const colors = {
|
|
231
|
+
primary: semantic.primary ?? tokens.colors[0] ?? FALLBACK_COLORS.primary,
|
|
232
|
+
secondary:
|
|
233
|
+
semantic.secondary ?? tokens.colors[1] ?? FALLBACK_COLORS.secondary,
|
|
234
|
+
accent: semantic.accent ?? tokens.colors[0] ?? FALLBACK_COLORS.accent,
|
|
235
|
+
background:
|
|
236
|
+
semantic.background ?? tokens.colors[2] ?? FALLBACK_COLORS.background,
|
|
237
|
+
surface: semantic.surface ?? tokens.colors[3] ?? FALLBACK_COLORS.surface,
|
|
238
|
+
text: semantic.text ?? tokens.colors[4] ?? FALLBACK_COLORS.text,
|
|
239
|
+
textMuted:
|
|
240
|
+
semantic.textMuted ?? tokens.colors[5] ?? FALLBACK_COLORS.textMuted,
|
|
241
|
+
};
|
|
242
|
+
const heading = tokens.components?.find(
|
|
243
|
+
(component) => component.role === "heading",
|
|
244
|
+
);
|
|
245
|
+
const body = tokens.components?.find(
|
|
246
|
+
(component) => component.role === "body",
|
|
247
|
+
);
|
|
248
|
+
const typography = tokens.typography;
|
|
249
|
+
const headingTypography = typography[0];
|
|
250
|
+
const bodyTypography = typography[1] ?? typography[0];
|
|
251
|
+
const source = input.finalUrl ?? input.url;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
colors,
|
|
255
|
+
typography: {
|
|
256
|
+
headingFont:
|
|
257
|
+
firstFontFamily(heading?.fontFamily ?? headingTypography?.family) ||
|
|
258
|
+
"system-ui",
|
|
259
|
+
bodyFont:
|
|
260
|
+
firstFontFamily(body?.fontFamily ?? bodyTypography?.family) ||
|
|
261
|
+
"system-ui",
|
|
262
|
+
headingWeight: heading?.fontWeight ?? headingTypography?.weight ?? "700",
|
|
263
|
+
bodyWeight: body?.fontWeight ?? bodyTypography?.weight ?? "400",
|
|
264
|
+
headingSizes: {
|
|
265
|
+
h1: componentFontSize(tokens, "heading", "h1") ?? "48px",
|
|
266
|
+
h2: componentFontSize(tokens, "heading", "h2") ?? "36px",
|
|
267
|
+
h3: componentFontSize(tokens, "heading", "h3") ?? "24px",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
spacing: {
|
|
271
|
+
pagePadding: tokens.layout?.pagePadding ?? tokens.spacing[0] ?? "24px",
|
|
272
|
+
elementGap: tokens.layout?.sectionGap ?? tokens.spacing[1] ?? "16px",
|
|
273
|
+
},
|
|
274
|
+
borders: {
|
|
275
|
+
radius: tokens.radii[0] ?? "8px",
|
|
276
|
+
accentWidth: "0px",
|
|
277
|
+
},
|
|
278
|
+
logos: (input.assets ?? [])
|
|
279
|
+
.filter((asset) => asset.role === "logo")
|
|
280
|
+
.slice(0, 12)
|
|
281
|
+
.map((asset, index) => ({
|
|
282
|
+
url: asset.url,
|
|
283
|
+
name: `Logo ${index + 1}`,
|
|
284
|
+
variant: "auto" as const,
|
|
285
|
+
})),
|
|
286
|
+
tokens: tokensToBrandKitTokens(tokens, source),
|
|
287
|
+
notes: [
|
|
288
|
+
`Source: ${source}`,
|
|
289
|
+
input.title ? `Page title: ${input.title}` : "",
|
|
290
|
+
"The values above were inferred from the live computed browser cascade.",
|
|
291
|
+
input.designMd,
|
|
292
|
+
]
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
.join("\n\n"),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function buildDesignMarkdown(input: {
|
|
299
|
+
url: string;
|
|
300
|
+
finalUrl: string;
|
|
301
|
+
title: string;
|
|
302
|
+
rendered: boolean;
|
|
303
|
+
method: RenderedPageResult["method"];
|
|
304
|
+
confidence: number;
|
|
305
|
+
designTokens: WebsiteDesignTokens;
|
|
306
|
+
warnings: string[];
|
|
307
|
+
}): string {
|
|
308
|
+
const tokens = input.designTokens;
|
|
309
|
+
const semantic = tokens.semanticColors ?? {};
|
|
310
|
+
const lines = [
|
|
311
|
+
`# ${safeHeading(input.title || new URL(input.finalUrl).hostname)} design system`,
|
|
312
|
+
"",
|
|
313
|
+
`Source: ${input.finalUrl}`,
|
|
314
|
+
`Extraction: ${input.rendered ? "real browser computed styles" : "SSRF-safe static HTML fallback"} (${input.method}; confidence ${Math.round(input.confidence * 100)}%)`,
|
|
315
|
+
"",
|
|
316
|
+
"## Colors",
|
|
317
|
+
...roleLines(semantic),
|
|
318
|
+
...(tokens.colors.length > 0
|
|
319
|
+
? ["", `Observed palette: ${tokens.colors.slice(0, 16).join(", ")}`]
|
|
320
|
+
: ["", "No visible color samples were available."]),
|
|
321
|
+
"",
|
|
322
|
+
"## Typography",
|
|
323
|
+
...tokens.typography
|
|
324
|
+
.slice(0, 12)
|
|
325
|
+
.map(
|
|
326
|
+
(style) =>
|
|
327
|
+
`- ${style.family}; ${style.size}; weight ${style.weight}; line-height ${style.lineHeight}; tracking ${style.letterSpacing}`,
|
|
328
|
+
),
|
|
329
|
+
...(tokens.typography.length === 0
|
|
330
|
+
? ["- No visible typography samples were available."]
|
|
331
|
+
: []),
|
|
332
|
+
"",
|
|
333
|
+
"## Spacing, shape, and depth",
|
|
334
|
+
`- Spacing: ${tokens.spacing.slice(0, 20).join(", ") || "not observed"}`,
|
|
335
|
+
`- Radii: ${tokens.radii.slice(0, 12).join(", ") || "not observed"}`,
|
|
336
|
+
`- Shadows: ${(tokens.shadows ?? []).slice(0, 8).join("; ") || "not observed"}`,
|
|
337
|
+
`- Background treatments: ${(tokens.backgrounds ?? []).slice(0, 8).join("; ") || "not observed"}`,
|
|
338
|
+
...(tokens.layout
|
|
339
|
+
? [
|
|
340
|
+
`- Layout: content width ${tokens.layout.contentWidth ?? "fluid"}; page padding ${tokens.layout.pagePadding ?? "not observed"}; section gap ${tokens.layout.sectionGap ?? "not observed"}`,
|
|
341
|
+
]
|
|
342
|
+
: []),
|
|
343
|
+
"",
|
|
344
|
+
"## Component language",
|
|
345
|
+
...(tokens.components ?? [])
|
|
346
|
+
.slice(0, 12)
|
|
347
|
+
.map((component) =>
|
|
348
|
+
[
|
|
349
|
+
`- ${component.role}:`,
|
|
350
|
+
component.fontFamily,
|
|
351
|
+
component.fontSize ? `size ${component.fontSize}` : undefined,
|
|
352
|
+
component.fontWeight ? `weight ${component.fontWeight}` : undefined,
|
|
353
|
+
component.color ? `color ${component.color}` : undefined,
|
|
354
|
+
component.backgroundColor
|
|
355
|
+
? `background ${component.backgroundColor}`
|
|
356
|
+
: undefined,
|
|
357
|
+
component.backgroundImage
|
|
358
|
+
? `background image ${component.backgroundImage}`
|
|
359
|
+
: undefined,
|
|
360
|
+
component.borderRadius
|
|
361
|
+
? `radius ${component.borderRadius}`
|
|
362
|
+
: undefined,
|
|
363
|
+
component.boxShadow ? `shadow ${component.boxShadow}` : undefined,
|
|
364
|
+
component.padding ? `padding ${component.padding}` : undefined,
|
|
365
|
+
component.textTransform
|
|
366
|
+
? `case ${component.textTransform}`
|
|
367
|
+
: undefined,
|
|
368
|
+
]
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.join("; "),
|
|
371
|
+
),
|
|
372
|
+
...((tokens.components ?? []).length === 0
|
|
373
|
+
? ["- No representative visible component styles were available."]
|
|
374
|
+
: []),
|
|
375
|
+
"",
|
|
376
|
+
"## CSS variables",
|
|
377
|
+
...Object.entries(tokens.cssVariables)
|
|
378
|
+
.slice(0, 64)
|
|
379
|
+
.map(([name, value]) => `- ${name}: ${value}`),
|
|
380
|
+
...(Object.keys(tokens.cssVariables).length === 0
|
|
381
|
+
? ["- No root CSS custom properties were available."]
|
|
382
|
+
: []),
|
|
383
|
+
...(input.warnings.length > 0
|
|
384
|
+
? [
|
|
385
|
+
"",
|
|
386
|
+
"## Extraction notes",
|
|
387
|
+
...input.warnings.map((warning) => `- ${warning}`),
|
|
388
|
+
]
|
|
389
|
+
: []),
|
|
390
|
+
];
|
|
391
|
+
return lines.join("\n").slice(0, MAX_DESIGN_MD_CHARS);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function failedExtraction(
|
|
395
|
+
url: string,
|
|
396
|
+
error: unknown,
|
|
397
|
+
): RenderedDesignExtraction {
|
|
398
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
399
|
+
return {
|
|
400
|
+
status: "failed",
|
|
401
|
+
url,
|
|
402
|
+
rendered: false,
|
|
403
|
+
warnings: [],
|
|
404
|
+
diagnostics: [],
|
|
405
|
+
error: message,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function roleLines(
|
|
410
|
+
colors: NonNullable<WebsiteDesignTokens["semanticColors"]>,
|
|
411
|
+
): string[] {
|
|
412
|
+
const roles = [
|
|
413
|
+
"primary",
|
|
414
|
+
"secondary",
|
|
415
|
+
"accent",
|
|
416
|
+
"background",
|
|
417
|
+
"surface",
|
|
418
|
+
"text",
|
|
419
|
+
"textMuted",
|
|
420
|
+
] as const;
|
|
421
|
+
return roles.map((role) => `- ${role}: ${colors[role] ?? "not observed"}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function firstFontFamily(value: string | undefined): string {
|
|
425
|
+
return (
|
|
426
|
+
value
|
|
427
|
+
?.split(",")[0]
|
|
428
|
+
?.trim()
|
|
429
|
+
.replace(/^['"]|['"]$/g, "") ?? ""
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function uniqueStrings(values: string[]): string[] {
|
|
434
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function typographyPolicy(
|
|
438
|
+
heading: NonNullable<WebsiteDesignTokens["components"]>[number] | undefined,
|
|
439
|
+
body: NonNullable<WebsiteDesignTokens["components"]>[number] | undefined,
|
|
440
|
+
): string | undefined {
|
|
441
|
+
if (!heading && !body) return undefined;
|
|
442
|
+
return [
|
|
443
|
+
heading
|
|
444
|
+
? `Headings use ${firstFontFamily(heading.fontFamily)} at ${heading.fontWeight ?? "default"} weight.`
|
|
445
|
+
: "",
|
|
446
|
+
body
|
|
447
|
+
? `Body copy uses ${firstFontFamily(body.fontFamily)} at ${body.fontWeight ?? "default"} weight.`
|
|
448
|
+
: "",
|
|
449
|
+
]
|
|
450
|
+
.filter(Boolean)
|
|
451
|
+
.join(" ");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function componentFontSize(
|
|
455
|
+
tokens: WebsiteDesignTokens,
|
|
456
|
+
role: "heading",
|
|
457
|
+
level: "h1" | "h2" | "h3",
|
|
458
|
+
): string | undefined {
|
|
459
|
+
const heading = tokens.components?.find(
|
|
460
|
+
(component) => component.role === role,
|
|
461
|
+
);
|
|
462
|
+
if (!heading?.fontSize) return undefined;
|
|
463
|
+
if (level === "h1") return heading.fontSize;
|
|
464
|
+
const numeric = Number.parseFloat(heading.fontSize);
|
|
465
|
+
if (!Number.isFinite(numeric)) return heading.fontSize;
|
|
466
|
+
return `${Math.max(level === "h2" ? 24 : 18, Math.round(numeric * (level === "h2" ? 0.75 : 0.58)))}px`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function tokensToBrandKitTokens(
|
|
470
|
+
tokens: WebsiteDesignTokens,
|
|
471
|
+
source: string,
|
|
472
|
+
): BrandKitToken[] {
|
|
473
|
+
const entries = Object.entries(tokens.cssVariables).slice(0, MAX_TOKENS);
|
|
474
|
+
const cssTokens = entries.map(([cssVar, value]) => ({
|
|
475
|
+
name: friendlyTokenName(cssVar),
|
|
476
|
+
cssVar,
|
|
477
|
+
value,
|
|
478
|
+
type: classifyBrandKitToken(cssVar, value),
|
|
479
|
+
source,
|
|
480
|
+
}));
|
|
481
|
+
const observedColors = tokens.colors.slice(0, 32).map((value, index) => ({
|
|
482
|
+
name: `Observed Color ${index + 1}`,
|
|
483
|
+
cssVar: `--observed-color-${index + 1}`,
|
|
484
|
+
value,
|
|
485
|
+
type: "color" as const satisfies BrandKitTokenType,
|
|
486
|
+
source,
|
|
487
|
+
}));
|
|
488
|
+
return [...cssTokens, ...observedColors].slice(0, MAX_TOKENS);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function safeHeading(value: string): string {
|
|
492
|
+
return (
|
|
493
|
+
value
|
|
494
|
+
.replace(/[\r\n#]/g, " ")
|
|
495
|
+
.trim()
|
|
496
|
+
.slice(0, 160) || "Website"
|
|
497
|
+
);
|
|
498
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const isBlockedExtensionUrlWithDns = vi.hoisted(() => vi.fn(async () => false));
|
|
4
|
+
|
|
5
|
+
vi.mock("@agent-native/core/extensions/url-safety", () => ({
|
|
6
|
+
isBlockedExtensionUrlWithDns,
|
|
7
|
+
ssrfSafeFetch: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const { renderWithPlaywright } = await import("./rendered-page.js");
|
|
11
|
+
|
|
12
|
+
describe("renderWithPlaywright lifecycle", () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("closes isolated contexts and reports bounded stabilization failures", async () => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
const events: string[] = [];
|
|
20
|
+
let evaluateCalls = 0;
|
|
21
|
+
const page = {
|
|
22
|
+
async route() {},
|
|
23
|
+
async goto() {},
|
|
24
|
+
async waitForLoadState(state: string) {
|
|
25
|
+
throw new Error(`${state} timed out`);
|
|
26
|
+
},
|
|
27
|
+
async title() {
|
|
28
|
+
return "Example";
|
|
29
|
+
},
|
|
30
|
+
url() {
|
|
31
|
+
return "https://example.com/";
|
|
32
|
+
},
|
|
33
|
+
locator() {
|
|
34
|
+
return { innerText: async () => "Example content" };
|
|
35
|
+
},
|
|
36
|
+
async setViewportSize() {},
|
|
37
|
+
async screenshot() {
|
|
38
|
+
return new Uint8Array([1]);
|
|
39
|
+
},
|
|
40
|
+
async evaluate() {
|
|
41
|
+
evaluateCalls += 1;
|
|
42
|
+
if (evaluateCalls === 1) return new Promise(() => {});
|
|
43
|
+
if (evaluateCalls === 2) return undefined;
|
|
44
|
+
throw new Error("computed styles unavailable");
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const context = {
|
|
48
|
+
pages: () => [],
|
|
49
|
+
newPage: async () => page,
|
|
50
|
+
async close() {
|
|
51
|
+
events.push("context.close");
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const browser = {
|
|
55
|
+
contexts: () => [],
|
|
56
|
+
async newContext() {
|
|
57
|
+
events.push("context.new");
|
|
58
|
+
return context;
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
events.push("browser.close");
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const renderPromise = renderWithPlaywright(
|
|
65
|
+
{
|
|
66
|
+
chromium: {
|
|
67
|
+
async launch() {
|
|
68
|
+
return browser;
|
|
69
|
+
},
|
|
70
|
+
async connectOverCDP() {
|
|
71
|
+
return browser;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as never,
|
|
75
|
+
{ url: "https://example.com/", timeoutMs: 1_000 },
|
|
76
|
+
[],
|
|
77
|
+
"local-playwright",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
81
|
+
const result = await renderPromise;
|
|
82
|
+
|
|
83
|
+
expect(events).toEqual(["context.new", "context.close", "browser.close"]);
|
|
84
|
+
expect(result.warnings).toEqual(
|
|
85
|
+
expect.arrayContaining([
|
|
86
|
+
"Browser load stabilization unavailable: load timed out",
|
|
87
|
+
"Browser network-idle stabilization unavailable: networkidle timed out",
|
|
88
|
+
"Browser font readiness unavailable: font readiness timed out after 1000ms",
|
|
89
|
+
"Browser style extraction unavailable: computed styles unavailable",
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
expect(result.diagnostics).toEqual(expect.arrayContaining(result.warnings));
|
|
93
|
+
});
|
|
94
|
+
});
|