@navipartner/demo-video 0.4.0 → 0.5.1
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 +128 -109
- package/bin/args.d.mts +39 -30
- package/bin/args.mjs +158 -153
- package/bin/demo-video.mjs +425 -272
- package/bin/encode.mjs +187 -187
- package/bin/init.d.mts +30 -24
- package/bin/init.mjs +520 -447
- package/bin/pick.d.mts +23 -18
- package/bin/pick.mjs +265 -252
- package/bin/pitch.d.mts +17 -0
- package/bin/pitch.mjs +261 -0
- package/dist/{adapter-CWqMoNdw.d.ts → adapter-D_iU2dh2.d.ts} +19 -0
- package/dist/index.d.ts +3 -3
- package/dist/runner/index.d.ts +2 -2
- package/dist/runner/index.js +61 -3
- package/package.json +1 -1
- package/skills/np-demo-video/SKILL.md +364 -348
- package/skills/np-demo-video/linear.md +117 -0
- package/skills/np-demo-video/look.md +158 -158
- package/skills/np-demo-video/reference.md +92 -82
package/bin/pitch.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// The brand half of `demo-video pitch <proposal.json>`: turns a pitch
|
|
7
|
+
// research's proposal into a brand folder for one render. The proposal comes
|
|
8
|
+
// from an AI that read the open web, so nothing in it is trusted: colours,
|
|
9
|
+
// fonts and config values are checked, and assets are downloaded here (not
|
|
10
|
+
// by the AI), over https, as images only. An SVG with script in it is
|
|
11
|
+
// refused. What goes back into the workflow and the reply (the customer,
|
|
12
|
+
// links, notes) is one line each, and plain text.
|
|
13
|
+
|
|
14
|
+
const MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
// Sites behind a bot challenge (Cloudflare) refuse a bare fetch.
|
|
16
|
+
const HEADERS = {
|
|
17
|
+
"user-agent":
|
|
18
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36",
|
|
19
|
+
accept: "image/avif,image/webp,image/svg+xml,image/*,*/*;q=0.8",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, "a #rrggbb colour");
|
|
23
|
+
// A new line would start another workflow output.
|
|
24
|
+
const line = z.string().refine(
|
|
25
|
+
// eslint-disable-next-line no-control-regex -- they are what it rejects
|
|
26
|
+
(v) => !/[\u0000-\u001f\u007f]/.test(v),
|
|
27
|
+
"one line, no control characters",
|
|
28
|
+
);
|
|
29
|
+
const httpsUrl = line
|
|
30
|
+
.pipe(z.string().max(2000))
|
|
31
|
+
.refine((v) => {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(v).protocol === "https:";
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}, "an https URL")
|
|
38
|
+
// Normalised, so it can't close the <…> it's printed in.
|
|
39
|
+
.transform((v) => new URL(v).href);
|
|
40
|
+
const font = z
|
|
41
|
+
.string()
|
|
42
|
+
.regex(/^[\w .-]{1,64}$/, "a font name: letters, digits, spaces, . and -");
|
|
43
|
+
// Values go into the app's own branding API mock: no markup, no CSS breakout.
|
|
44
|
+
const configValue = z.union([
|
|
45
|
+
z
|
|
46
|
+
.string()
|
|
47
|
+
.max(200)
|
|
48
|
+
.refine((v) => !/[<>{};`]/.test(v), "no < > { } ; or ` in a value"),
|
|
49
|
+
z.number(),
|
|
50
|
+
z.boolean(),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const proposalSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
// The brand's tenantName takes 64 at most.
|
|
56
|
+
customer: line.pipe(z.string().min(1).max(64)),
|
|
57
|
+
website: httpsUrl,
|
|
58
|
+
sources: z.array(httpsUrl).min(1).max(20),
|
|
59
|
+
config: z.record(z.string().regex(/^\w{1,64}$/), configValue).default({}),
|
|
60
|
+
assets: z
|
|
61
|
+
.record(z.string().regex(/^[A-Za-z]{1,32}$/), httpsUrl)
|
|
62
|
+
.default({}),
|
|
63
|
+
video: z
|
|
64
|
+
.object({
|
|
65
|
+
primary: hex,
|
|
66
|
+
ink: hex,
|
|
67
|
+
accent: hex,
|
|
68
|
+
text: hex,
|
|
69
|
+
headingFont: font,
|
|
70
|
+
bodyFont: font,
|
|
71
|
+
})
|
|
72
|
+
.strict(),
|
|
73
|
+
logoColor: hex.optional(),
|
|
74
|
+
notes: line.pipe(z.string().max(1000)).optional(),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
|
|
78
|
+
/** "Northwind Århus" → "pitch-northwind-arhus". */
|
|
79
|
+
export const pitchName = (customer) => {
|
|
80
|
+
const slug = customer
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/æ/g, "ae")
|
|
83
|
+
.replace(/ø/g, "o")
|
|
84
|
+
.replace(/ß/g, "ss")
|
|
85
|
+
.normalize("NFKD")
|
|
86
|
+
.replace(/[̀-ͯ]/g, "")
|
|
87
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
88
|
+
.replace(/^-+|-+$/g, "")
|
|
89
|
+
.slice(0, 40)
|
|
90
|
+
.replace(/-+$/, "");
|
|
91
|
+
return `pitch-${slug || "customer"}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Text for the reply: no links, markup or mentions a web page slipped in. */
|
|
95
|
+
export const plainText = (text) =>
|
|
96
|
+
text
|
|
97
|
+
.replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, "(link removed)")
|
|
98
|
+
.replace(/\bwww\.\S+/gi, "(link removed)")
|
|
99
|
+
.replace(/[[\]<>`*_~|\\]/g, "")
|
|
100
|
+
// A # starts a heading or an issue link, except in a colour (#1F5494).
|
|
101
|
+
.replace(/#(?![0-9a-f]{6}\b|[0-9a-f]{3}\b)/gi, "")
|
|
102
|
+
.replace(/@/g, "@\u200b")
|
|
103
|
+
.trim();
|
|
104
|
+
|
|
105
|
+
const SVG_DANGER =
|
|
106
|
+
/<script|<foreignObject|<iframe|<embed|<object|\son\w+\s*=|javascript:/i;
|
|
107
|
+
|
|
108
|
+
/** The image type of a download, by its bytes, or null. */
|
|
109
|
+
export const imageType = (bytes) => {
|
|
110
|
+
const b = bytes;
|
|
111
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e) {
|
|
112
|
+
return ".png";
|
|
113
|
+
}
|
|
114
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
115
|
+
return ".jpg";
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
b.length >= 12 &&
|
|
119
|
+
b.toString("latin1", 0, 4) === "RIFF" &&
|
|
120
|
+
b.toString("latin1", 8, 12) === "WEBP"
|
|
121
|
+
) {
|
|
122
|
+
return ".webp";
|
|
123
|
+
}
|
|
124
|
+
// An SVG file starts with <svg, after an XML declaration, a doctype or
|
|
125
|
+
// comments. A web page with an inline icon doesn't.
|
|
126
|
+
const head = b
|
|
127
|
+
.toString("utf8", 0, Math.min(b.length, 4096))
|
|
128
|
+
.replace(/^\uFEFF/, "")
|
|
129
|
+
.replace(/^(\s+|<\?xml[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>)*/i, "");
|
|
130
|
+
if (/^<svg[\s/>]/i.test(head) && !head.includes("\u0000")) {
|
|
131
|
+
return ".svg";
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const download = async (url, fetchImpl) => {
|
|
137
|
+
const parsed = new URL(url);
|
|
138
|
+
if (parsed.protocol !== "https:") {
|
|
139
|
+
throw new Error(`${url}: only https downloads`);
|
|
140
|
+
}
|
|
141
|
+
const response = await fetchImpl(url, {
|
|
142
|
+
headers: HEADERS,
|
|
143
|
+
redirect: "follow",
|
|
144
|
+
signal: AbortSignal.timeout(20_000),
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new Error(`${url}: HTTP ${response.status}`);
|
|
148
|
+
}
|
|
149
|
+
if (response.url && new URL(response.url).protocol !== "https:") {
|
|
150
|
+
throw new Error(`${url}: redirected off https`);
|
|
151
|
+
}
|
|
152
|
+
// The usual mistake: the page the logo is on, not the logo's own URL.
|
|
153
|
+
if (/text\/html/i.test(response.headers.get("content-type") ?? "")) {
|
|
154
|
+
throw new Error(`${url}: a web page, not an image file`);
|
|
155
|
+
}
|
|
156
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
157
|
+
if (length > MAX_ASSET_BYTES) {
|
|
158
|
+
throw new Error(`${url}: larger than 8 MB`);
|
|
159
|
+
}
|
|
160
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
161
|
+
if (bytes.length > MAX_ASSET_BYTES) {
|
|
162
|
+
throw new Error(`${url}: larger than 8 MB`);
|
|
163
|
+
}
|
|
164
|
+
const ext = imageType(bytes);
|
|
165
|
+
if (!ext) {
|
|
166
|
+
throw new Error(`${url}: not an SVG, PNG, JPEG or WebP image`);
|
|
167
|
+
}
|
|
168
|
+
if (ext === ".svg" && SVG_DANGER.test(bytes.toString("utf8"))) {
|
|
169
|
+
throw new Error(`${url}: an SVG with script or embedded content`);
|
|
170
|
+
}
|
|
171
|
+
return { bytes, ext };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// The video loads its fonts from Google Fonts: a name it doesn't know would
|
|
175
|
+
// silently fall back to a system font.
|
|
176
|
+
const checkGoogleFont = async (family, fetchImpl) => {
|
|
177
|
+
const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}`;
|
|
178
|
+
const response = await fetchImpl(url, {
|
|
179
|
+
signal: AbortSignal.timeout(20_000),
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`"${family}" is not a Google Fonts family (fonts.googleapis.com answered ${response.status})`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// WCAG contrast of a #rrggbb colour against white.
|
|
189
|
+
export const contrastOnWhite = (colour) => {
|
|
190
|
+
const n = parseInt(colour.slice(1), 16);
|
|
191
|
+
const channel = (c) => {
|
|
192
|
+
const v = c / 255;
|
|
193
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
194
|
+
};
|
|
195
|
+
const l =
|
|
196
|
+
0.2126 * channel((n >> 16) & 255) +
|
|
197
|
+
0.7152 * channel((n >> 8) & 255) +
|
|
198
|
+
0.0722 * channel(n & 255);
|
|
199
|
+
return 1.05 / (l + 0.05);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Builds `<outDir>/<name>.json` and `<outDir>/<name>/` from a proposal.
|
|
204
|
+
* Returns what the reply names: the brand, the customer and the sources.
|
|
205
|
+
*/
|
|
206
|
+
export const buildPitchBrand = async (
|
|
207
|
+
proposal,
|
|
208
|
+
outDir,
|
|
209
|
+
{ fetch: fetchImpl = globalThis.fetch } = {},
|
|
210
|
+
) => {
|
|
211
|
+
const result = proposalSchema.safeParse(proposal);
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`The proposal isn't a brand: ${result.error.issues
|
|
215
|
+
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
216
|
+
.join("; ")}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const p = result.data;
|
|
220
|
+
const name = pitchName(p.customer);
|
|
221
|
+
// Headings and text sit on pale cards.
|
|
222
|
+
for (const key of ["ink", "text"]) {
|
|
223
|
+
const ratio = contrastOnWhite(p.video[key]);
|
|
224
|
+
if (ratio < 4.5) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`video.${key} ${p.video[key]} doesn't read on white (contrast ${ratio.toFixed(1)}, needs 4.5)`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await checkGoogleFont(p.video.headingFont, fetchImpl);
|
|
231
|
+
if (p.video.bodyFont !== p.video.headingFont) {
|
|
232
|
+
await checkGoogleFont(p.video.bodyFont, fetchImpl);
|
|
233
|
+
}
|
|
234
|
+
const assetDir = path.join(outDir, name);
|
|
235
|
+
fs.rmSync(assetDir, { recursive: true, force: true });
|
|
236
|
+
fs.mkdirSync(assetDir, { recursive: true });
|
|
237
|
+
const assets = {};
|
|
238
|
+
for (const [slot, url] of Object.entries(p.assets)) {
|
|
239
|
+
const { bytes, ext } = await download(url, fetchImpl);
|
|
240
|
+
fs.writeFileSync(path.join(assetDir, `${slot}${ext}`), bytes);
|
|
241
|
+
assets[slot] = `${name}/${slot}${ext}`;
|
|
242
|
+
}
|
|
243
|
+
const brand = {
|
|
244
|
+
tenantName: p.customer,
|
|
245
|
+
config: p.config,
|
|
246
|
+
assets,
|
|
247
|
+
video: p.video,
|
|
248
|
+
...(p.logoColor ? { logoColor: p.logoColor } : {}),
|
|
249
|
+
};
|
|
250
|
+
fs.writeFileSync(
|
|
251
|
+
path.join(outDir, `${name}.json`),
|
|
252
|
+
JSON.stringify(brand, null, 2),
|
|
253
|
+
);
|
|
254
|
+
return {
|
|
255
|
+
brand: name,
|
|
256
|
+
customer: plainText(p.customer),
|
|
257
|
+
website: p.website,
|
|
258
|
+
sources: p.sources,
|
|
259
|
+
notes: plainText(p.notes ?? ""),
|
|
260
|
+
};
|
|
261
|
+
};
|
|
@@ -112,6 +112,17 @@ interface DemoAction<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
|
112
112
|
params: z.ZodObject<S>;
|
|
113
113
|
run(d: DirectorApi, params: z.infer<z.ZodObject<S>>): Promise<void>;
|
|
114
114
|
}
|
|
115
|
+
/** One thing a brand gives the app: what it is and where it shows. */
|
|
116
|
+
type BrandNeed = string | {
|
|
117
|
+
what: string;
|
|
118
|
+
optional?: boolean;
|
|
119
|
+
};
|
|
120
|
+
interface BrandNeeds {
|
|
121
|
+
/** `brand.config` keys that `setup` applies. */
|
|
122
|
+
config?: Record<string, BrandNeed>;
|
|
123
|
+
/** Asset slots that `setup` uses (`brand.assetUrls[slot]`). */
|
|
124
|
+
assets?: Record<string, BrandNeed>;
|
|
125
|
+
}
|
|
115
126
|
interface DemoAdapter {
|
|
116
127
|
/** Must equal the storyboard's `app`. */
|
|
117
128
|
app: string;
|
|
@@ -148,6 +159,14 @@ interface DemoAdapter {
|
|
|
148
159
|
brands?: string;
|
|
149
160
|
/** Default "navipartner". */
|
|
150
161
|
defaultBrand?: string;
|
|
162
|
+
/**
|
|
163
|
+
* What this app takes from a brand, each with what it is and where it
|
|
164
|
+
* shows ("full-screen photo behind the menu, landscape"). Apps differ: one
|
|
165
|
+
* shows a background image, another a secondary colour. Whoever makes a
|
|
166
|
+
* brand reads this (`demo-video brand-needs`), and a brand without a need
|
|
167
|
+
* that isn't `optional` fails `check`.
|
|
168
|
+
*/
|
|
169
|
+
brandNeeds?: BrandNeeds;
|
|
151
170
|
/** App-specific steps, used in storyboards as { "do": "<name>", …params }. */
|
|
152
171
|
actions?: Record<string, DemoAction<any>>;
|
|
153
172
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { D as DemoAdapter } from './adapter-
|
|
2
|
-
export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-
|
|
1
|
+
import { D as DemoAdapter } from './adapter-D_iU2dh2.js';
|
|
2
|
+
export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-D_iU2dh2.js';
|
|
3
3
|
import { Page } from '@playwright/test';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export { z } from 'zod';
|
|
@@ -240,7 +240,7 @@ interface Storyboard {
|
|
|
240
240
|
brand?: string;
|
|
241
241
|
/** The story a variant extends. */
|
|
242
242
|
variantOf?: string;
|
|
243
|
-
/** What the story shows, for people (and the
|
|
243
|
+
/** What the story shows, for people choosing one (and the menu). */
|
|
244
244
|
description?: string;
|
|
245
245
|
/** The app's primary journey: the story filmed when none is named. One per app. */
|
|
246
246
|
primary?: boolean;
|
package/dist/runner/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PlaywrightTestConfig, Page } from '@playwright/test';
|
|
2
|
-
import { D as DemoAdapter } from '../adapter-
|
|
2
|
+
import { D as DemoAdapter } from '../adapter-D_iU2dh2.js';
|
|
3
3
|
import 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -7,7 +7,7 @@ import 'zod';
|
|
|
7
7
|
* `defineConfig` and loads no Playwright of its own. `demoDir` is the app's
|
|
8
8
|
* demo/ folder; `runnerDir` holds the spec the CLI generated.
|
|
9
9
|
*/
|
|
10
|
-
declare const createDemoConfig: (adapter: DemoAdapter, demoDir: string, runnerDir: string) => PlaywrightTestConfig;
|
|
10
|
+
declare const createDemoConfig: (adapter: DemoAdapter, demoDir: string, runnerDir: string, mode?: string | undefined) => PlaywrightTestConfig;
|
|
11
11
|
|
|
12
12
|
/** The app's own Playwright `test`, passed in so only one Playwright loads. */
|
|
13
13
|
type DemoTest = (title: string, body: (args: {
|
package/dist/runner/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
|
|
35
35
|
// src/runner/config.ts
|
|
36
36
|
import path from "path";
|
|
37
|
-
var createDemoConfig = (adapter, demoDir, runnerDir) => ({
|
|
37
|
+
var createDemoConfig = (adapter, demoDir, runnerDir, mode = process.env.DEMO_MODE) => ({
|
|
38
38
|
testDir: runnerDir,
|
|
39
39
|
testMatch: ["demo.spec.mjs"],
|
|
40
40
|
// Beside the runner, in the run's work folder: kept only on failure.
|
|
@@ -55,7 +55,7 @@ var createDemoConfig = (adapter, demoDir, runnerDir) => ({
|
|
|
55
55
|
video: "off",
|
|
56
56
|
screenshot: "off"
|
|
57
57
|
},
|
|
58
|
-
webServer: {
|
|
58
|
+
webServer: mode === "needs" ? void 0 : {
|
|
59
59
|
command: adapter.webServer.command,
|
|
60
60
|
// demo/ sits in the app's root, where its dev script lives.
|
|
61
61
|
cwd: path.dirname(demoDir),
|
|
@@ -213,6 +213,34 @@ var routeBrandAssets = async (page, brand) => {
|
|
|
213
213
|
);
|
|
214
214
|
}
|
|
215
215
|
};
|
|
216
|
+
var VIDEO_NEEDS = {
|
|
217
|
+
tenantName: "The customer's name, as the app shows it.",
|
|
218
|
+
"assets.logo": "The customer's logo: SVG, or a PNG with a transparent background. Shown on the video's cards.",
|
|
219
|
+
"video.primary": "The main brand colour (#rrggbb): full-bleed cards, the emphasis word, the background tint.",
|
|
220
|
+
"video.ink": "A dark brand colour for headings on pale cards. Must read well on white.",
|
|
221
|
+
"video.accent": "A lighter brand colour for the soft background bloom.",
|
|
222
|
+
"video.text": "Body text on pale cards: near-black.",
|
|
223
|
+
"video.headingFont": "A Google Fonts family for headings.",
|
|
224
|
+
"video.bodyFont": "A Google Fonts family for sub-lines.",
|
|
225
|
+
logoColor: "Optional: replaces white fills in an SVG logo, so a white logo shows on pale cards."
|
|
226
|
+
};
|
|
227
|
+
var needOf = (need) => typeof need === "string" ? { what: need, optional: false } : { what: need.what, optional: need.optional === true };
|
|
228
|
+
var describeNeeds = (needs = {}) => ({
|
|
229
|
+
config: Object.fromEntries(
|
|
230
|
+
Object.entries(needs.config ?? {}).map(([k, v]) => [k, needOf(v)])
|
|
231
|
+
),
|
|
232
|
+
assets: Object.fromEntries(
|
|
233
|
+
Object.entries(needs.assets ?? {}).map(([k, v]) => [k, needOf(v)])
|
|
234
|
+
)
|
|
235
|
+
});
|
|
236
|
+
var missingNeeds = (brand, needs = {}) => {
|
|
237
|
+
const { config, assets } = describeNeeds(needs);
|
|
238
|
+
const empty = (value) => value === void 0 || value === null || value === "";
|
|
239
|
+
return [
|
|
240
|
+
...Object.entries(config).filter(([key, need]) => !need.optional && empty(brand.config[key])).map(([key, need]) => `config.${key}: ${need.what}`),
|
|
241
|
+
...Object.entries(assets).filter(([slot, need]) => !need.optional && !brand.assets[slot]).map(([slot, need]) => `assets.${slot}: ${need.what}`)
|
|
242
|
+
];
|
|
243
|
+
};
|
|
216
244
|
|
|
217
245
|
// src/changes.ts
|
|
218
246
|
var watchChanges = (target) => target.evaluate((el) => {
|
|
@@ -1075,6 +1103,7 @@ var readRunEnv = (env = process.env) => {
|
|
|
1075
1103
|
outDir,
|
|
1076
1104
|
workDir: env.DEMO_WORK || `${outDir}/.work`,
|
|
1077
1105
|
brand: env.DEMO_BRAND || void 0,
|
|
1106
|
+
brandsDir: env.DEMO_BRANDS || void 0,
|
|
1078
1107
|
draft: env.DEMO_DRAFT === "1"
|
|
1079
1108
|
};
|
|
1080
1109
|
};
|
|
@@ -1124,6 +1153,23 @@ var writeJson = (file, value) => {
|
|
|
1124
1153
|
fs3.writeFileSync(file, JSON.stringify(value, null, 2));
|
|
1125
1154
|
};
|
|
1126
1155
|
var registerDemoTests = (test, adapter, demoDir) => {
|
|
1156
|
+
if (process.env.DEMO_MODE === "needs") {
|
|
1157
|
+
test("demo", async () => {
|
|
1158
|
+
writeJson(path4.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
|
|
1159
|
+
app: adapter.app,
|
|
1160
|
+
viewport: adapter.viewport ?? DEFAULT_VIEWPORT,
|
|
1161
|
+
video: VIDEO_NEEDS,
|
|
1162
|
+
appNeeds: describeNeeds(adapter.brandNeeds),
|
|
1163
|
+
example: JSON.parse(
|
|
1164
|
+
fs3.readFileSync(
|
|
1165
|
+
path4.join(sharedBrandsDir(), "navipartner.json"),
|
|
1166
|
+
"utf8"
|
|
1167
|
+
)
|
|
1168
|
+
)
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1127
1173
|
test("demo", async ({ page }) => {
|
|
1128
1174
|
const env = readRunEnv();
|
|
1129
1175
|
if (env.mode === "check") {
|
|
@@ -1156,9 +1202,21 @@ var registerDemoTests = (test, adapter, demoDir) => {
|
|
|
1156
1202
|
);
|
|
1157
1203
|
}
|
|
1158
1204
|
const brand = loadBrand(
|
|
1159
|
-
|
|
1205
|
+
[
|
|
1206
|
+
...env.brandsDir ? [env.brandsDir] : [],
|
|
1207
|
+
...brandDirs(demoDir, adapter.brands)
|
|
1208
|
+
],
|
|
1160
1209
|
env.brand ?? storyboard.brand ?? adapter.defaultBrand ?? "navipartner"
|
|
1161
1210
|
);
|
|
1211
|
+
const missing = missingNeeds(brand, adapter.brandNeeds);
|
|
1212
|
+
if (missing.length > 0) {
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
[
|
|
1215
|
+
`Brand "${brand.name}" lacks what ${adapter.app} needs (the adapter's brandNeeds):`,
|
|
1216
|
+
...missing.map((m) => ` ${m}`)
|
|
1217
|
+
].join("\n")
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1162
1220
|
await adapter.setup(page, { brand });
|
|
1163
1221
|
await routeBrandAssets(page, brand);
|
|
1164
1222
|
await page.route(
|
package/package.json
CHANGED