webview-preview 0.0.1 → 1.1.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/src/index.d.ts CHANGED
@@ -6,13 +6,21 @@ export type Step =
6
6
  | `wait:${number}`
7
7
  | `waitfor:${string}`
8
8
  | `measure:${string}`
9
+ | `assert:${string}`
10
+ | "focusorder"
11
+ | `focusorder:${number}`
12
+ | `film:${string}:${number}`
13
+ | "offline"
14
+ | "online"
15
+ | `latency:${number}`
9
16
  | `snap:${string}`
10
17
  | `print:${string}`
11
18
  | `resize:${number}x${number}`
12
19
  | `key:${string}`
13
20
  | `type:${string}`
14
21
  | `hover:${string}`
15
- | `click:${string}`;
22
+ | `click:${string}`
23
+ | `macro:${string}`;
16
24
 
17
25
  export interface ServerOptions {
18
26
  command: string;
@@ -26,7 +34,13 @@ export interface PreviewConfig {
26
34
  baseUrl?: string;
27
35
  server?: ServerOptions;
28
36
  engine?: Engine;
29
- size?: `${number}x${number}`;
37
+ size?: Size | Size[] | string;
38
+ colorScheme?: "light" | "dark";
39
+ reducedMotion?: boolean;
40
+ offline?: boolean;
41
+ latency?: number;
42
+ watch?: string | string[];
43
+ report?: boolean;
30
44
  out?: string;
31
45
  prefix?: string;
32
46
  scale?: number;
@@ -42,6 +56,22 @@ export interface PreviewConfig {
42
56
  baseline?: string;
43
57
  maxDiffPixels?: number;
44
58
  compareTolerance?: number;
59
+ tauri?: boolean | TauriStub;
60
+ globals?: Record<string, unknown>;
61
+ locale?: string;
62
+ macros?: Record<string, Step[] | string[]>;
63
+ failOnError?: boolean;
64
+ }
65
+
66
+ export type Size = `${number}x${number}`;
67
+
68
+ export interface TauriStub {
69
+ commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
70
+ label?: string;
71
+ }
72
+
73
+ export interface AnyApi {
74
+ readonly members: Record<string, unknown>;
45
75
  }
46
76
 
47
77
  export interface MockResponse {
@@ -66,7 +96,11 @@ export interface PreviewOptions extends PreviewConfig {
66
96
  export interface PreviewResult {
67
97
  engine: Exclude<Engine, "auto">;
68
98
  url: string;
99
+ sizes: string[];
69
100
  saved: string[];
101
+ consoleErrors: number;
102
+ failedAsserts: number;
103
+ report?: string;
70
104
  failed: boolean;
71
105
  }
72
106
 
@@ -74,7 +108,9 @@ export declare const ENGINES: Engine[];
74
108
  export declare const REQUESTS_KEY: "__webviewPreviewRequests";
75
109
  export declare function resolveUrl(target: string, baseUrl?: string): string;
76
110
  export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
111
+ export declare function watchPreview(options?: PreviewOptions, onRun?: (result: PreviewResult) => void): Promise<() => void>;
77
112
  export declare function defineConfig(config: PreviewConfig): PreviewConfig;
113
+ export declare function anyApi(members?: Record<string, unknown>): AnyApi;
78
114
  export interface CleanOptions {
79
115
  config?: string | false;
80
116
  out?: string;
package/src/index.mjs CHANGED
@@ -1,16 +1,24 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { compareSnapshots } from "#src/snapshots/compare.mjs";
4
+ import { stitchFilm } from "#src/snapshots/film.mjs";
5
+ import { writeReport } from "#src/snapshots/report.mjs";
4
6
  import { loadConfig } from "#src/core/config.mjs";
5
7
  import { resolveEngine, runEngine } from "#src/engines/index.mjs";
6
- import { resolveInit } from "#src/page/init.mjs";
8
+ import { consoleScript, consoleStep, watchOutput } from "#src/page/console.mjs";
9
+ import { checkLocale, globalsScript, localeScript } from "#src/page/globals.mjs";
10
+ import { resolveInit, wrapInit } from "#src/page/init.mjs";
7
11
  import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
8
12
  import { startServer } from "#src/core/server.mjs";
9
- import { parseSize, parseSteps } from "#src/core/steps.mjs";
13
+ import { watchFiles } from "#src/core/watch.mjs";
14
+ import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
15
+ import { checkLatency, networkScript } from "#src/page/network.mjs";
16
+ import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
10
17
 
11
18
  export { ENGINES } from "#src/engines/index.mjs";
12
19
  export { REQUESTS_KEY } from "#src/page/mock.mjs";
13
20
  export { cleanSnapshots } from "#src/snapshots/clean.mjs";
21
+ export { anyApi } from "#src/page/source.mjs";
14
22
 
15
23
  const DEFAULTS = { engine: "auto", size: "1200x650", out: ".webview-preview", steps: ["snap:page"] }
16
24
  const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:/i;
@@ -23,7 +31,23 @@ const consoleLog = {
23
31
  export const resolveUrl = (target, baseUrl) => {
24
32
  if (ABSOLUTE_URL.test(target)) return target;
25
33
  if (!baseUrl) throw new Error(`"${target}" is not a full URL; set baseUrl to open routes`);
26
- return `${baseUrl}${target.startsWith("/") ? target : `/${target}`}`;
34
+ const route = target.startsWith("/") ? target : `/${target}`;
35
+ return baseUrl.endsWith("/") ? `${baseUrl}${route.slice(1)}` : `${baseUrl}${route}`;
36
+ }
37
+
38
+ const COLOR_SCHEMES = ["light", "dark"];
39
+
40
+ const parseSizes = value => {
41
+ const sizes = [].concat(value).flatMap(item => String(item).split(",")).map(item => item.trim()).filter(Boolean);
42
+ if (!sizes.length) throw new Error("size needs at least one WxH");
43
+ for (const size of sizes) parseSize(size);
44
+ return sizes;
45
+ }
46
+
47
+ const checkColorScheme = value => {
48
+ if (value === undefined) return undefined;
49
+ if (!COLOR_SCHEMES.includes(value)) throw new Error(`colorScheme must be ${COLOR_SCHEMES.join(" or ")}, got "${value}"`);
50
+ return value;
27
51
  }
28
52
 
29
53
  export const preview = async (options = {}) => {
@@ -31,51 +55,172 @@ export const preview = async (options = {}) => {
31
55
  const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
32
56
  const baseDir = config.configDir ?? process.cwd();
33
57
  const engine = resolveEngine(merged.engine);
34
- const steps = parseSteps(merged.steps.length ? merged.steps : DEFAULTS.steps);
58
+ const macros = { ...config.macros, ...options.macros }
59
+ const steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
35
60
  const out = path.resolve(baseDir, merged.out);
36
61
  const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
37
- parseSize(merged.size);
62
+ const sizes = parseSizes(merged.size);
63
+ const snapMargin = Number(merged.snapMargin ?? 8);
64
+ const colorScheme = checkColorScheme(merged.colorScheme);
38
65
  const mockFiles = await resolveMockFiles(options.mockFile, process.cwd());
39
66
  const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
40
67
  || !!(options.logRequests ?? config.logRequests);
41
68
  const mockRules = compileMock([config.mock, ...mockFiles, options.mock]);
42
- const init = (hasMock ? [mockScript(mockRules)] : []).concat(resolveInit([
43
- { entries: config.init, baseDir },
44
- { entries: options.init, baseDir: process.cwd() },
45
- { entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
46
- ], baseDir));
69
+ const locale = merged.locale === undefined ? undefined : checkLocale(String(merged.locale));
70
+ const tauri = resolveTauri(config.tauri, options.tauri);
71
+ const globals = { ...config.globals, ...options.globals }
72
+ const network = { isOffline: !!merged.offline, latencyMs: checkLatency(merged.latency) }
73
+ const checked = stubs => stubs.filter(Boolean).map(stub => {
74
+ try {
75
+ new Function(stub.source);
76
+ } catch (error) {
77
+ throw new Error(`${stub.label}: the generated script does not parse (${error.message}); check the functions in your config`);
78
+ }
79
+ return wrapInit(stub);
80
+ });
81
+ const init = [
82
+ ...checked([
83
+ { label: "console", source: consoleScript() },
84
+ locale && { label: "locale", source: localeScript(locale) },
85
+ tauri && { label: "tauri", source: tauriScript(tauri) },
86
+ Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
87
+ ]),
88
+ ...checked([hasMock && { label: "mock", source: mockScript(mockRules) }]),
89
+ ...checked([{ label: "network", source: networkScript(network) }]),
90
+ ...resolveInit([
91
+ { entries: config.init, baseDir },
92
+ { entries: options.init, baseDir: process.cwd() },
93
+ { entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
94
+ ], baseDir)
95
+ ];
47
96
  mkdirSync(out, { recursive: true });
48
97
 
98
+ const baseLog = merged.log ?? consoleLog;
99
+ const lines = [];
100
+ const log = {
101
+ info: message => {
102
+ lines.push(["info", message]);
103
+ baseLog.info(message);
104
+ },
105
+ error: message => {
106
+ lines.push(["error", message]);
107
+ baseLog.error(message);
108
+ }
109
+ }
110
+ const output = watchOutput(log);
49
111
  const server = merged.server && { cwd: baseDir, ...merged.server }
50
112
  const stopServer = await startServer(server);
51
113
  try {
52
- const result = await runEngine(engine, {
53
- url,
54
- steps,
55
- init,
56
- size: merged.size,
57
- out,
58
- prefix: merged.prefix ?? `${engine}-`,
59
- scale: merged.scale === undefined ? undefined : Number(merged.scale),
60
- snapMargin: Number(merged.snapMargin ?? 8),
61
- isHeaded: !!merged.headed,
62
- isStorageKept: !!merged.keepStorage,
63
- waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
64
- log: merged.log ?? consoleLog
65
- });
114
+ const prefix = merged.prefix ?? `${engine}-`;
115
+ const result = { saved: [], failed: false }
116
+ for (const size of sizes) {
117
+ if (sizes.length > 1) output.log.info(`size ${size}`);
118
+ const run = await runEngine(engine, {
119
+ url,
120
+ steps: [...steps, consoleStep],
121
+ init,
122
+ size,
123
+ locale,
124
+ colorScheme,
125
+ isMotionReduced: !!merged.reducedMotion,
126
+ out,
127
+ prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix,
128
+ scale: merged.scale === undefined ? undefined : Number(merged.scale),
129
+ snapMargin,
130
+ isHeaded: !!merged.headed,
131
+ isStorageKept: !!merged.keepStorage,
132
+ waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
133
+ log: output.log
134
+ });
135
+ result.saved.push(...run.saved);
136
+ for (const film of run.films ?? []) {
137
+ try {
138
+ output.log.info(stitchFilm(film, snapMargin));
139
+ result.saved.push(film.file);
140
+ } catch (error) {
141
+ output.log.error(`film:${film.name} failed: ${error.message}`);
142
+ result.failed = true;
143
+ }
144
+ }
145
+ result.failed ||= run.failed;
146
+ }
147
+
148
+ const { consoleErrors, failedAsserts } = output.report();
66
149
  const shouldCompare = !!(merged.compare || merged.updateBaseline);
67
- const compareFailed = shouldCompare && compareSnapshots({
150
+ const compared = shouldCompare ? compareSnapshots({
68
151
  saved: result.saved,
69
152
  baselineDir: path.resolve(baseDir, merged.baseline ?? "webview-preview-baseline"),
70
153
  updateBaseline: !!merged.updateBaseline,
71
154
  tolerance: Number(merged.compareTolerance ?? 2),
72
155
  maxDiffPixels: Number(merged.maxDiffPixels ?? 0),
73
- log: merged.log ?? consoleLog
74
- });
75
- return { engine, url, ...result, failed: result.failed || compareFailed }
156
+ log
157
+ }) : { failed: false, results: [] }
158
+ const consoleFailed = !!merged.failOnError && consoleErrors > 0;
159
+ const failed = result.failed || compared.failed || consoleFailed || failedAsserts > 0;
160
+ const report = merged.report
161
+ ? writeReport({ out, url, engine, sizes, lines, saved: result.saved, compared: compared.results, failed })
162
+ : undefined;
163
+ if (report) baseLog.info(`report → ${report}`);
164
+ return { engine, url, sizes, ...result, consoleErrors, failedAsserts, report, failed }
76
165
  } finally {
77
166
  stopServer();
78
167
  }
79
168
  }
80
169
 
170
+ export const watchPreview = async (options = {}, onRun = () => {}) => {
171
+ const config = options.config === false ? {} : await loadConfig(options.config);
172
+ const baseDir = config.configDir ?? process.cwd();
173
+ const log = options.log ?? consoleLog;
174
+ const entries = [].concat(options.watch ?? config.watch ?? (existsSync(path.join(baseDir, "src")) ? "src" : "."));
175
+ const folders = entries.map(entry => path.resolve(baseDir, entry));
176
+ const isConfigWatched = !config.configFile || folders.some(folder => config.configFile === folder || config.configFile.startsWith(folder + path.sep));
177
+ const targets = isConfigWatched ? folders : [...folders, config.configFile];
178
+ const ignore = [
179
+ path.resolve(baseDir, options.out ?? config.out ?? DEFAULTS.out),
180
+ path.resolve(baseDir, options.baseline ?? config.baseline ?? "webview-preview-baseline")
181
+ ];
182
+ const server = options.server ?? config.server;
183
+ const stopServer = await startServer(server && { cwd: baseDir, ...server });
184
+ let isRunning = false;
185
+ let next = null;
186
+
187
+ const run = async reason => {
188
+ if (isRunning) {
189
+ next = reason;
190
+ return;
191
+ }
192
+ isRunning = true;
193
+ log.info(reason);
194
+ try {
195
+ onRun(await preview(options));
196
+ } catch (error) {
197
+ log.error(`webview-preview: ${error.message}`);
198
+ }
199
+ isRunning = false;
200
+ if (next) {
201
+ const queued = next;
202
+ next = null;
203
+ await run(queued);
204
+ }
205
+ }
206
+
207
+ let stopWatching;
208
+ try {
209
+ stopWatching = watchFiles({
210
+ targets,
211
+ ignore,
212
+ onChange: file => run(`\nchanged ${path.relative(baseDir, file) || file}, running again`)
213
+ });
214
+ } catch (error) {
215
+ stopServer();
216
+ throw error;
217
+ }
218
+
219
+ await run(`watching ${targets.map(target => path.relative(baseDir, target) || ".").join(", ")}; Ctrl+C to stop`);
220
+ return () => {
221
+ stopWatching();
222
+ stopServer();
223
+ }
224
+ }
225
+
81
226
  export const defineConfig = config => config;
@@ -0,0 +1,84 @@
1
+ import { ASSERT_MARKER, FOCUS_MARKER } from "#src/core/steps.mjs";
2
+
3
+ export const CONSOLE_KEY = "__webviewPreviewConsole";
4
+ const MARKER = "__webviewPreviewConsole:";
5
+
6
+ export const consoleScript = () => `(() => {
7
+ const entries = window.${CONSOLE_KEY} = window.${CONSOLE_KEY} || [];
8
+ const text = value => {
9
+ if (value instanceof Error) {
10
+ const head = value.name + ": " + value.message;
11
+ const stack = String(value.stack || "");
12
+ const frame = stack.split("\\n").find(line => line && !line.startsWith(head)) || "";
13
+ return frame ? head + " — " + frame.trim() : head;
14
+ }
15
+ if (typeof value === "string") return value;
16
+ try {
17
+ return JSON.stringify(value);
18
+ } catch {
19
+ return String(value);
20
+ }
21
+ }
22
+ const add = (kind, message) => {
23
+ const last = entries[entries.length - 1];
24
+ if (last && last.kind === kind && last.message === message) last.count += 1;
25
+ else entries.push({ kind, message: message.slice(0, 2000), count: 1 });
26
+ }
27
+ const originalError = console.error;
28
+ console.error = (...args) => {
29
+ add("console.error", args.map(text).join(" "));
30
+ return originalError.apply(console, args);
31
+ }
32
+ window.addEventListener("error", event => {
33
+ const where = event.filename ? " (" + event.filename.replace(location.origin, "") + ":" + event.lineno + ")" : "";
34
+ add("uncaught", event.error ? text(event.error) : event.message + where);
35
+ });
36
+ window.addEventListener("unhandledrejection", event => add("unhandled rejection", text(event.reason)));
37
+ })();`;
38
+
39
+ export const consoleStep = { kind: "print", value: `"${MARKER}" + JSON.stringify(window.${CONSOLE_KEY} || [])`, isInternal: true }
40
+
41
+ export const watchOutput = log => {
42
+ const entries = [];
43
+ let failedAsserts = 0;
44
+ let focus = null;
45
+ const printFocus = () => {
46
+ const { presses, items } = focus;
47
+ const firstPath = items[0]?.path;
48
+ const loopAt = items.findIndex((item, index) => index > 0 && item.path !== undefined && item.path === firstPath);
49
+ const shown = loopAt === -1 ? items : items.slice(0, loopAt);
50
+ log.info(`focus order (Tab ×${presses}):`);
51
+ shown.forEach((item, index) => log.info(` ${index + 1}. ${item.label ?? "(nothing focused)"}`));
52
+ if (loopAt !== -1) log.info(` → back to 1 after ${loopAt} stop${loopAt === 1 ? "" : "s"}`);
53
+ }
54
+ const info = message => {
55
+ if (typeof message !== "string") return log.info(message);
56
+ if (message.startsWith(MARKER)) {
57
+ entries.push(...JSON.parse(message.slice(MARKER.length)));
58
+ return;
59
+ }
60
+
61
+ if (message.startsWith(FOCUS_MARKER)) {
62
+ const [type, value, path] = JSON.parse(message.slice(FOCUS_MARKER.length));
63
+ if (type === "start") focus = { presses: value, items: [] }
64
+ else if (type === "item" && focus) focus.items.push({ label: value, path });
65
+ else if (type === "end" && focus) {
66
+ printFocus();
67
+ focus = null;
68
+ }
69
+ return;
70
+ }
71
+ if (message.startsWith(ASSERT_MARKER)) {
72
+ const [expression, problem] = JSON.parse(message.slice(ASSERT_MARKER.length));
73
+ if (problem === null) return log.info(`assert ok: ${expression}`);
74
+ failedAsserts += 1;
75
+ return log.error(`assert failed: ${expression} — ${problem}`);
76
+ }
77
+ log.info(message);
78
+ }
79
+ const report = () => {
80
+ for (const { kind, message, count } of entries) log.error(`${kind}: ${message}${count > 1 ? ` (×${count})` : ""}`);
81
+ return { consoleErrors: entries.length, failedAsserts }
82
+ }
83
+ return { log: { ...log, info }, report }
84
+ }
@@ -0,0 +1,37 @@
1
+ import { ANY_FUNCTION, toSource } from "#src/page/source.mjs";
2
+
3
+ const GLOBAL_PATH = /^(window\.)?[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
4
+ const LOCALE = /^[a-z]{2,3}(-[A-Za-z\d]{2,8})*$/i;
5
+
6
+ export const globalsScript = globals => {
7
+ const entries = Object.entries(globals).map(([path, value]) => {
8
+ if (!GLOBAL_PATH.test(path)) throw new Error(`globals: "${path}" must be a dotted path like window.api or window.go.main`);
9
+ const name = path.startsWith("window.") ? path : `window.${path}`;
10
+ return `assign(${JSON.stringify(name)}, ${toSource(value, name)});`;
11
+ });
12
+ return `(() => {
13
+ const __webviewPreviewAny = ${ANY_FUNCTION};
14
+ const assign = (path, value) => {
15
+ const parts = path.split(".").slice(1);
16
+ const last = parts.pop();
17
+ let target = window;
18
+ for (const part of parts) {
19
+ if (target[part] === undefined || target[part] === null) target[part] = {};
20
+ target = target[part];
21
+ }
22
+ target[last] = value;
23
+ }
24
+ ${entries.join("\n\t")}
25
+ })();`;
26
+ }
27
+
28
+ export const checkLocale = locale => {
29
+ if (!LOCALE.test(locale)) throw new Error(`locale must look like en-US or ru, got "${locale}"`);
30
+ return locale;
31
+ }
32
+
33
+ export const localeScript = locale => `(() => {
34
+ const language = ${JSON.stringify(checkLocale(locale))};
35
+ Object.defineProperty(Navigator.prototype, "language", { get: () => language, configurable: true });
36
+ Object.defineProperty(Navigator.prototype, "languages", { get: () => [language], configurable: true });
37
+ })();`;
package/src/page/init.mjs CHANGED
@@ -15,7 +15,7 @@ const readEntry = (entry, baseDir, index) => {
15
15
  throw new Error(`init entries must be code strings or { file: "path" }, got ${JSON.stringify(entry)}`);
16
16
  }
17
17
 
18
- const wrap = ({ label, source }) => `{
18
+ export const wrapInit = ({ label, source }) => `{
19
19
  try {
20
20
  ${source}
21
21
  } catch (error) {
@@ -25,7 +25,7 @@ const wrap = ({ label, source }) => `{
25
25
 
26
26
  export const resolveInit = (groups, baseDir) => groups
27
27
  .flatMap(group => toList(group.entries).map(entry => ({ entry, baseDir: group.baseDir })))
28
- .map(({ entry, baseDir: dir }, index) => wrap(readEntry(entry, dir ?? baseDir, index)));
28
+ .map(({ entry, baseDir: dir }, index) => wrapInit(readEntry(entry, dir ?? baseDir, index)));
29
29
 
30
30
  export const reportInitErrors = (json, log) => {
31
31
  for (const message of JSON.parse(json || "[]")) log.error(`init failed: ${message}`);
package/src/page/mock.mjs CHANGED
@@ -58,18 +58,19 @@ export const resolveMockFiles = async (files, baseDir) => Promise.all(
58
58
  export const mockScript = rules => `(() => {
59
59
  const rules = ${JSON.stringify(rules)}.map(rule => ({ ...rule, regex: new RegExp(rule.regex) }));
60
60
  const requests = window.${REQUESTS_KEY} = window.${REQUESTS_KEY} || [];
61
+ const pathOf = address => address.protocol === "file:" ? address.pathname.replace(/^\\/[A-Za-z]:/, "") : address.pathname;
61
62
  const find = (method, url) => {
62
63
  const address = new URL(url, location.href);
63
64
  return rules.find(rule => {
64
65
  if (rule.method && rule.method !== method) return false;
65
- const base = rule.isFullUrl ? address.origin + address.pathname : address.pathname;
66
+ const base = rule.isFullUrl ? address.origin + address.pathname : pathOf(address);
66
67
  return rule.regex.test(rule.withQuery ? base + address.search : base);
67
68
  });
68
69
  }
69
70
 
70
71
  const record = (method, url, rule) => {
71
72
  const address = new URL(url, location.href);
72
- const shown = address.origin === location.origin ? address.pathname + address.search : address.href;
73
+ const shown = address.origin === location.origin ? pathOf(address) + address.search : address.href;
73
74
  requests.push(method + " " + shown + (rule ? " -> mock " + rule.status : " -> network"));
74
75
  }
75
76
  const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -0,0 +1,79 @@
1
+ import { REQUESTS_KEY } from "#src/page/mock.mjs";
2
+
3
+ export const NETWORK_STEPS = new Set(["offline", "online", "latency"]);
4
+
5
+ export const checkLatency = value => {
6
+ if (value === undefined) return 0;
7
+ if (!/^\d+$/.test(String(value))) throw new Error(`latency needs milliseconds, got "${value}"`);
8
+ return Number(value);
9
+ }
10
+
11
+ export const networkStep = (kind, value, step) => {
12
+ if (kind === "latency" && !/^\d+$/.test(value)) throw new Error(`latency needs milliseconds, got "${step}"`);
13
+ if (kind !== "latency" && value) throw new Error(`${kind} takes no value, got "${step}"`);
14
+ const state = kind === "latency" ? { latencyMs: Number(value) } : { isOffline: kind === "offline" }
15
+ return { kind: "js", value: `window.__webviewPreview.network(${JSON.stringify(state)})` }
16
+ }
17
+
18
+ export const networkScript = ({ isOffline, latencyMs }) => `(() => {
19
+ const state = { isOffline: ${!!isOffline}, latencyMs: ${latencyMs} }
20
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
21
+ const record = (method, url) => {
22
+ const requests = window.${REQUESTS_KEY};
23
+ if (!requests) return;
24
+ const address = new URL(url, location.href);
25
+ const shown = address.origin === location.origin ? address.pathname + address.search : address.href;
26
+ requests.push(method + " " + shown + " -> offline");
27
+ }
28
+
29
+ const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine");
30
+ Object.defineProperty(Navigator.prototype, "onLine", {
31
+ configurable: true,
32
+ get() {
33
+ return state.isOffline ? false : onLine.get.call(this);
34
+ }
35
+ });
36
+
37
+ const network = next => {
38
+ const wasOffline = state.isOffline;
39
+ Object.assign(state, next);
40
+ if (wasOffline !== state.isOffline) window.dispatchEvent(new Event(state.isOffline ? "offline" : "online"));
41
+ }
42
+
43
+ const originalFetch = window.fetch;
44
+ window.fetch = async function (input, init) {
45
+ if (state.latencyMs) await wait(state.latencyMs);
46
+ if (state.isOffline) {
47
+ const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
48
+ record(String(init && init.method || input && input.method || "GET").toUpperCase(), url);
49
+ throw new TypeError("Failed to fetch");
50
+ }
51
+ return originalFetch.call(this, input, init);
52
+ }
53
+
54
+ const define = (target, name, value) => Object.defineProperty(target, name, { value, configurable: true });
55
+ const originalOpen = XMLHttpRequest.prototype.open;
56
+ const originalSend = XMLHttpRequest.prototype.send;
57
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
58
+ this.__webviewPreviewNetwork = { method: String(method).toUpperCase(), url: String(url) }
59
+ return originalOpen.call(this, method, url, ...rest);
60
+ }
61
+
62
+ XMLHttpRequest.prototype.send = function (body) {
63
+ const xhr = this;
64
+ const info = xhr.__webviewPreviewNetwork;
65
+ const send = () => {
66
+ if (!state.isOffline) return originalSend.call(xhr, body);
67
+ if (info) record(info.method, info.url);
68
+ define(xhr, "readyState", 4);
69
+ define(xhr, "status", 0);
70
+ xhr.dispatchEvent(new Event("readystatechange"));
71
+ xhr.dispatchEvent(new ProgressEvent("error"));
72
+ xhr.dispatchEvent(new ProgressEvent("loadend"));
73
+ }
74
+ if (state.latencyMs || state.isOffline) setTimeout(send, state.latencyMs);
75
+ else send();
76
+ }
77
+
78
+ window.__webviewPreview = Object.assign(window.__webviewPreview || {}, { network });
79
+ })();`;
@@ -0,0 +1,55 @@
1
+ const ANY_API = Symbol.for("webview-preview.anyApi");
2
+
3
+ export const anyApi = (members = {}) => ({ [ANY_API]: true, members });
4
+
5
+ const asExpression = source => {
6
+ try {
7
+ new Function(`return (${source})`);
8
+ return source;
9
+ } catch {
10
+ return source.startsWith("async ") ? `async function ${source.slice(6)}` : `function ${source}`;
11
+ }
12
+ }
13
+
14
+ export const toSource = (value, name = "value") => {
15
+ if (typeof value === "function") return `(${asExpression(value.toString())})`;
16
+ if (value === undefined) return "undefined";
17
+ if (Array.isArray(value)) return `[${value.map((item, index) => toSource(item, `${name}[${index}]`)).join(", ")}]`;
18
+ if (value && typeof value === "object") {
19
+ if (value[ANY_API]) return `__webviewPreviewAny(${toSource(value.members, name)}, ${JSON.stringify(name)})`;
20
+ const entries = Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}: ${toSource(item, `${name}.${key}`)}`);
21
+ return `{ ${entries.join(", ")} }`;
22
+ }
23
+ return JSON.stringify(value);
24
+ }
25
+
26
+ export const RECORD_FUNCTION = `call => (window.__webviewPreviewRequests = window.__webviewPreviewRequests || []).push(call)`;
27
+
28
+ export const ANY_FUNCTION = `(members, name) => {
29
+ const record = ${RECORD_FUNCTION};
30
+ const settled = () => new Proxy(function () {}, {
31
+ get: (_, key) => key === "then" ? resolve => resolve(null) : key === Symbol.toPrimitive ? () => "" : settled(),
32
+ apply: () => settled()
33
+ });
34
+
35
+ const node = path => new Proxy(function () {}, {
36
+ get: (_, key) => {
37
+ if (typeof key === "symbol") return key === Symbol.toPrimitive ? () => "" : undefined;
38
+ if (key === "then" || key === "toJSON") return undefined;
39
+ if (path === name && Object.prototype.hasOwnProperty.call(members, key)) {
40
+ const member = members[key];
41
+ if (typeof member !== "function") return member;
42
+ return (...args) => {
43
+ record("call " + path + "." + key + " -> stub");
44
+ return member(...args);
45
+ }
46
+ }
47
+ return node(path + "." + key);
48
+ },
49
+ apply: () => {
50
+ record("call " + path + " -> default");
51
+ return settled();
52
+ }
53
+ });
54
+ return node(name);
55
+ }`;