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/AGENTS.md +47 -7
- package/README.md +203 -69
- package/bin/webview-preview.mjs +76 -4
- package/package.json +4 -1
- package/src/core/config.mjs +5 -4
- package/src/core/steps.mjs +89 -3
- package/src/core/watch.mjs +31 -0
- package/src/engines/playwright.mjs +30 -3
- package/src/engines/webkitgtk.mjs +15 -5
- package/src/engines/webkitgtk.py +89 -11
- package/src/index.d.ts +38 -2
- package/src/index.mjs +174 -29
- package/src/page/console.mjs +84 -0
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +2 -2
- package/src/page/mock.mjs +3 -2
- package/src/page/network.mjs +79 -0
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -0
- package/src/snapshots/compare.mjs +10 -2
- package/src/snapshots/film.mjs +51 -0
- package/src/snapshots/report.mjs +71 -0
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?:
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
74
|
-
})
|
|
75
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
+
}`;
|