@zntc/core 0.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/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/banner.mjs +131 -0
- package/bin/cli-flags.mjs +473 -0
- package/bin/rn-asset-copy.mjs +151 -0
- package/bin/rn-dev-input.mjs +225 -0
- package/bin/verify.mjs +190 -0
- package/bin/zntc.mjs +2327 -0
- package/dist/core/index.d.cts +1311 -0
- package/dist/core/index.d.ts +1311 -0
- package/dist/core/src/config-loader.d.ts +157 -0
- package/dist/core/src/load-env.d.ts +31 -0
- package/dist/core/src/platforms.d.ts +35 -0
- package/dist/core/src/runtime-polyfills.d.ts +94 -0
- package/dist/core/src/schema-allowlists.d.ts +17 -0
- package/dist/core/src/typo-suggest.d.ts +38 -0
- package/dist/core/src/workspace.d.ts +185 -0
- package/dist/index.cjs +1608 -0
- package/dist/index.js +3637 -0
- package/dist/shared/compat-engines.d.ts +30 -0
- package/dist/shared/index.d.ts +185 -0
- package/package.json +97 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// `runRnDev` 의 config + opts 추출 helper. zntc.mjs 에서 import + 사용.
|
|
2
|
+
// 별도 module 로 분리해 단위 테스트가 zntc.mjs 의 entry main() 트리거 없이
|
|
3
|
+
// helper 만 import 가능.
|
|
4
|
+
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* config 의 미지원 필드는 stderr 에 한 번 경고 — silent drop 방지. zntc 의
|
|
9
|
+
* RN dev server 가 아직 wire-up 안 한 영역 (graph-bundler 전용 필드 + dummy
|
|
10
|
+
* placeholder) 을 사용자가 적었을 때 "왜 동작 안 함?" 디버깅 비용 절감.
|
|
11
|
+
*/
|
|
12
|
+
const UNSUPPORTED_FIELDS = [
|
|
13
|
+
// graph-bundler (Metro 호환) 전용 — zntc NAPI build 는 미수용.
|
|
14
|
+
['transformer', 'inlineRequires'],
|
|
15
|
+
['transformer', 'minifier'],
|
|
16
|
+
['serializer', 'bundleType'],
|
|
17
|
+
['serializer', 'getModulesRunBeforeMainModule'],
|
|
18
|
+
['serializer', 'getRunModuleStatement'],
|
|
19
|
+
['serializer', 'shouldAddToIgnoreList'],
|
|
20
|
+
// dummy placeholder — bungae 도 declared-but-unused.
|
|
21
|
+
['server', 'verifyConnections'],
|
|
22
|
+
['server', 'https'],
|
|
23
|
+
['server', 'key'],
|
|
24
|
+
['server', 'cert'],
|
|
25
|
+
['server', 'unstable_serverRoot'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function warnUnsupported(config) {
|
|
29
|
+
for (const [section, key] of UNSUPPORTED_FIELDS) {
|
|
30
|
+
const sectionVal = config?.[section];
|
|
31
|
+
if (sectionVal && Object.prototype.hasOwnProperty.call(sectionVal, key)) {
|
|
32
|
+
process.stderr.write(`[zntc:rn-dev] config.${section}.${key} (zntc 미지원, ignore)\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeStringArray(value, label) {
|
|
38
|
+
if (value === undefined || value === null) return [];
|
|
39
|
+
if (!Array.isArray(value)) {
|
|
40
|
+
throw new TypeError(`config.serializer.${label} must be a string array`);
|
|
41
|
+
}
|
|
42
|
+
for (const item of value) {
|
|
43
|
+
if (typeof item !== 'string') {
|
|
44
|
+
throw new TypeError(`config.serializer.${label} must contain only strings`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSerializerPolyfills(serializer, opts = {}) {
|
|
51
|
+
const polyfills = [];
|
|
52
|
+
|
|
53
|
+
const platform = opts.rnPlatform === 'android' ? 'android' : 'ios';
|
|
54
|
+
if (typeof serializer.getPolyfills === 'function') {
|
|
55
|
+
const generated = serializer.getPolyfills({ platform });
|
|
56
|
+
if (generated && typeof generated.then === 'function') {
|
|
57
|
+
throw new TypeError('async config.serializer.getPolyfills() is not supported');
|
|
58
|
+
}
|
|
59
|
+
polyfills.push(...normalizeStringArray(generated, 'getPolyfills() result'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Metro prepends getPolyfills() and then appends serializer.polyfillModuleNames.
|
|
63
|
+
polyfills.push(...normalizeStringArray(serializer.polyfillModuleNames, 'polyfillModuleNames'));
|
|
64
|
+
// ZNTC also keeps the existing serializer.polyfills compatibility field.
|
|
65
|
+
polyfills.push(...normalizeStringArray(serializer.polyfills, 'polyfills'));
|
|
66
|
+
|
|
67
|
+
return polyfills.length > 0 ? polyfills : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Metro-shape config (`resolver` / `serializer` / `transformer` / `server` /
|
|
72
|
+
* `watchFolders` / `sourcemapSourcesRoot`) → `RnBundleInput.extra` 평탄화. dev
|
|
73
|
+
* server (rn-dev-input) 와 prod bundle (zntc.mjs runRnBundle) 양쪽이 공유.
|
|
74
|
+
*
|
|
75
|
+
* `opts.rnWatchFolders` / `opts.rnSourceExts` 만 CLI flag 가 config 위에 우선 —
|
|
76
|
+
* 나머지 필드는 config-only.
|
|
77
|
+
*/
|
|
78
|
+
export function buildRnBundleExtra(config, opts = {}) {
|
|
79
|
+
const cfg = config ?? {};
|
|
80
|
+
const resolver = cfg.resolver ?? {};
|
|
81
|
+
const transformer = cfg.transformer ?? {};
|
|
82
|
+
const serializer = cfg.serializer ?? {};
|
|
83
|
+
const server = cfg.server ?? {};
|
|
84
|
+
return {
|
|
85
|
+
watchFolders: opts.rnWatchFolders ?? cfg.watchFolders ?? undefined,
|
|
86
|
+
nodeModulesPaths: resolver.nodeModulesPaths ?? undefined,
|
|
87
|
+
sourceExts: opts.rnSourceExts ?? resolver.sourceExts ?? undefined,
|
|
88
|
+
assetExts: resolver.assetExts ?? undefined,
|
|
89
|
+
platforms: resolver.platforms ?? undefined,
|
|
90
|
+
blockList: resolver.blockList ?? undefined,
|
|
91
|
+
fallback: resolver.extraNodeModules ?? undefined,
|
|
92
|
+
metroResolveRequest: resolver.resolveRequest ?? undefined,
|
|
93
|
+
babelTransformerPath: transformer.babelTransformerPath ?? undefined,
|
|
94
|
+
polyfills: getSerializerPolyfills(serializer, opts),
|
|
95
|
+
extraVars: serializer.extraVars ?? undefined,
|
|
96
|
+
prelude: serializer.prelude ?? undefined,
|
|
97
|
+
babel: transformer.babel ?? undefined,
|
|
98
|
+
inlineSourceMap: serializer.inlineSourceMap ?? undefined,
|
|
99
|
+
sourceRoot: cfg.sourcemapSourcesRoot ?? undefined,
|
|
100
|
+
silentConsoleErrorPatterns: server.silentConsoleErrorPatterns ?? undefined,
|
|
101
|
+
forwardClientLogs: server.forwardClientLogs !== false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function assignRnBuildOptionOverrides(out, config, opts = {}) {
|
|
106
|
+
const cfg = config ?? {};
|
|
107
|
+
if (opts.experimentalDecorators === true || cfg.experimentalDecorators === true) {
|
|
108
|
+
out.experimentalDecorators = true;
|
|
109
|
+
}
|
|
110
|
+
if (opts.emitDecoratorMetadata === true || cfg.emitDecoratorMetadata === true) {
|
|
111
|
+
out.emitDecoratorMetadata = true;
|
|
112
|
+
}
|
|
113
|
+
if (opts.useDefineForClassFields === false || cfg.useDefineForClassFields === false) {
|
|
114
|
+
out.useDefineForClassFields = false;
|
|
115
|
+
}
|
|
116
|
+
if (opts.inlineDynamicImports === true || cfg.inlineDynamicImports === true) {
|
|
117
|
+
out.inlineDynamicImports = true;
|
|
118
|
+
} else if (opts.inlineDynamicImports === false || cfg.inlineDynamicImports === false) {
|
|
119
|
+
out.inlineDynamicImports = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Internal helper — RN dev/bundle 경로의 `bundle.override` 객체를 구성한다.
|
|
125
|
+
* `@zntc/core` package.json `exports` 에 노출되지 않는 CLI bin 전용. 외부에서
|
|
126
|
+
* deep import 로 호출하지 말 것.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} input
|
|
129
|
+
* @param {object} [input.config] 사용자 `zntc.config.{ts,js,json}` 의 root 객체.
|
|
130
|
+
* `alias` / `moduleSpecifierMap` 같은 dict 타입은 RN preset 으로 forwarding.
|
|
131
|
+
* `experimentalDecorators` 등은 BuildOptions override 로 forward.
|
|
132
|
+
* @param {object} [input.opts] CLI flag 가 우선 적용된 RuntimeOptions
|
|
133
|
+
* (`experimentalDecorators`/`emitDecoratorMetadata`/`useDefineForClassFields`).
|
|
134
|
+
* config 와 OR 로 합산.
|
|
135
|
+
* @param {object} [input.override] 호출자(zntc.mjs) 가 build 결과 후 caller-side
|
|
136
|
+
* write 를 처리할 때 강제로 덮어 씌우고 싶은 BuildOptions (예: `outfile`,
|
|
137
|
+
* `write`). 마지막에 `Object.assign` 으로 머지.
|
|
138
|
+
* @returns {object | undefined} 빈 object 면 `undefined` 반환 — caller 의 spread/
|
|
139
|
+
* merge 로직이 noop 으로 처리되도록.
|
|
140
|
+
*/
|
|
141
|
+
export function buildRnBundleOverride({ config, opts = {}, override } = {}) {
|
|
142
|
+
const cfg = config ?? {};
|
|
143
|
+
const out = {};
|
|
144
|
+
if (
|
|
145
|
+
(cfg.alias && typeof cfg.alias === 'object') ||
|
|
146
|
+
(opts.alias && typeof opts.alias === 'object')
|
|
147
|
+
) {
|
|
148
|
+
// RN 경로에서도 일반 zntc `--alias`/config.alias 를 동일하게 적용한다.
|
|
149
|
+
// Babel module-resolver 를 제외한 프로젝트가 alias 를 native resolver 로 옮길 때 필요하다.
|
|
150
|
+
out.alias = { ...(cfg.alias ?? {}), ...(opts.alias ?? {}) };
|
|
151
|
+
}
|
|
152
|
+
if (cfg.moduleSpecifierMap && typeof cfg.moduleSpecifierMap === 'object') {
|
|
153
|
+
out.moduleSpecifierMap = cfg.moduleSpecifierMap;
|
|
154
|
+
}
|
|
155
|
+
assignRnBuildOptionOverrides(out, cfg, opts);
|
|
156
|
+
if (override && typeof override === 'object') {
|
|
157
|
+
Object.assign(out, override);
|
|
158
|
+
}
|
|
159
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 번개 BungaeConfig 의 `root` / `entry` / `dev` / `minify` / `server.*` /
|
|
164
|
+
* `resolver.*` / `transformer.*` / `serializer.*` / `symbolicator.*` /
|
|
165
|
+
* `watchFolders` 영역 인식.
|
|
166
|
+
*
|
|
167
|
+
* CLI flag 우선 → config 영역 → default 순서 fallback. entry 가 없으면 null —
|
|
168
|
+
* caller 가 friendly error 처리.
|
|
169
|
+
*
|
|
170
|
+
* 미지원 필드 (graph-bundler 전용 + placeholder) 는 stderr 에 한 번 경고 —
|
|
171
|
+
* `transformer.inlineRequires` / `transformer.minifier` / `serializer.prelude` /
|
|
172
|
+
* `serializer.bundleType` / `server.forwardClientLogs` / `server.verifyConnections` 등.
|
|
173
|
+
*/
|
|
174
|
+
export function buildRnDevServerInput(opts, config) {
|
|
175
|
+
const cfg = config ?? {};
|
|
176
|
+
const projectRoot = resolve(opts.rnProjectRoot ?? cfg.projectRoot ?? cfg.root ?? '.');
|
|
177
|
+
const entry = opts.entryPoints?.[0] ?? cfg.entry;
|
|
178
|
+
if (!entry) return null;
|
|
179
|
+
const rnPlatform = opts.rnPlatform === 'android' ? 'android' : 'ios';
|
|
180
|
+
const server = cfg.server ?? {};
|
|
181
|
+
const resolver = cfg.resolver ?? {};
|
|
182
|
+
const symbolicator = cfg.symbolicator ?? {};
|
|
183
|
+
|
|
184
|
+
warnUnsupported(cfg);
|
|
185
|
+
|
|
186
|
+
// server.useGlobalHotkey + CLI `--no-interactive` 둘 다 r/d/? 키보드
|
|
187
|
+
// shortcut 의 enable gate. CLI flag 우선 — 명시 시 config override.
|
|
188
|
+
const terminalActions =
|
|
189
|
+
opts.noInteractive === true || server.useGlobalHotkey === false ? false : undefined;
|
|
190
|
+
const dev = opts.devMode !== undefined ? Boolean(opts.devMode) : cfg.dev !== false;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
bundle: {
|
|
194
|
+
entry,
|
|
195
|
+
projectRoot,
|
|
196
|
+
rnPlatform,
|
|
197
|
+
// dev server 는 default __DEV__=true / sourcemap=true (bundle 의 default false 와 의도적 비대칭).
|
|
198
|
+
dev,
|
|
199
|
+
// dev server 는 항상 sourcemap=true — RN LogBox / DevTools 의 source link
|
|
200
|
+
// 동작에 필수. zntc.mjs 의 `opts.sourcemap` default 가 false 라 `!== false`
|
|
201
|
+
// 비교는 무용 (사용자 명시 disable 구분 불가). dev server 컨텍스트에선
|
|
202
|
+
// sourcemap 필수 default.
|
|
203
|
+
sourcemap: true,
|
|
204
|
+
minify:
|
|
205
|
+
opts.minify ||
|
|
206
|
+
opts.minifyWhitespace ||
|
|
207
|
+
opts.minifyIdentifiers ||
|
|
208
|
+
opts.minifySyntax ||
|
|
209
|
+
cfg.minify ||
|
|
210
|
+
false,
|
|
211
|
+
extra: buildRnBundleExtra(cfg, opts),
|
|
212
|
+
override: buildRnBundleOverride({ config: cfg, opts }),
|
|
213
|
+
},
|
|
214
|
+
port: opts.port ?? server.port ?? 8081,
|
|
215
|
+
host: opts.host ?? server.host ?? 'localhost',
|
|
216
|
+
nodeModulesPaths: resolver.nodeModulesPaths ?? [],
|
|
217
|
+
enhanceMiddleware: server.enhanceMiddleware,
|
|
218
|
+
rewriteRequestUrl: server.rewriteRequestUrl,
|
|
219
|
+
symbolicator: symbolicator.customizeFrame
|
|
220
|
+
? { customizeFrame: symbolicator.customizeFrame }
|
|
221
|
+
: undefined,
|
|
222
|
+
...(server.hmr === false ? { hmr: false } : {}),
|
|
223
|
+
...(terminalActions === false ? { terminalActions: false } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
package/bin/verify.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `zntc verify <path-or-url>` 진입점. 정적 결과물 (`dist/`) 또는 URL 을 헤드리스
|
|
2
|
+
// Chromium 에 띄우고 pageerror / console.error / 4xx 응답 / request 실패를
|
|
3
|
+
// 수집해 JSON 리포트로 리턴. CI 한 줄로 "번들이 브라우저에서 깨지는지" 검증.
|
|
4
|
+
//
|
|
5
|
+
// playwright 는 optionalDependency — 미설치 시 친절한 안내 후 exit 64.
|
|
6
|
+
|
|
7
|
+
import { statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
// Exit code contract — runVerify 의 public API. CI 가 의존하므로 변경 시 docs/USAGE.md 동기.
|
|
13
|
+
const EXIT_OK = 0;
|
|
14
|
+
const EXIT_EVENTS_FOUND = 1;
|
|
15
|
+
const EXIT_LOAD_FAILED = 2;
|
|
16
|
+
const EXIT_BAD_USAGE = 64;
|
|
17
|
+
|
|
18
|
+
const PLAYWRIGHT_INSTALL_HINT = [
|
|
19
|
+
'zntc verify requires Playwright to be installed.',
|
|
20
|
+
'',
|
|
21
|
+
' npm install --save-dev playwright',
|
|
22
|
+
' npx playwright install chromium',
|
|
23
|
+
'',
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
26
|
+
// cwd 의 node_modules 와 verify.mjs 위치 양쪽에서 playwright / @playwright/test 를
|
|
27
|
+
// 순차 탐색. 사용자가 자기 프로젝트에 playwright 를 설치한 경우 그쪽 우선.
|
|
28
|
+
// require() 가 dynamic import 보다 우선 — @playwright/test 는 CJS/ESM dual 인데
|
|
29
|
+
// CJS entry 를 ESM 으로 평가하면 default export 만 노출되고 chromium 같은 named
|
|
30
|
+
// export 는 사라진다. require() 는 CJS entry 의 module.exports 전체를 그대로 반환.
|
|
31
|
+
async function loadChromium() {
|
|
32
|
+
const requireFromCwd = createRequire(resolve(process.cwd(), 'package.json'));
|
|
33
|
+
for (const name of ['playwright', '@playwright/test']) {
|
|
34
|
+
try {
|
|
35
|
+
const mod = requireFromCwd(name);
|
|
36
|
+
if (mod?.chromium) return mod.chromium;
|
|
37
|
+
} catch {}
|
|
38
|
+
try {
|
|
39
|
+
const mod = await import(name);
|
|
40
|
+
if (mod?.chromium) return mod.chromium;
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runVerify(opts) {
|
|
47
|
+
const target = normalizeTarget(opts.verifyTarget);
|
|
48
|
+
if (!target) {
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
'zntc verify: missing or invalid target. Usage: zntc verify <path-or-url> [options]\n',
|
|
51
|
+
);
|
|
52
|
+
return { exitCode: EXIT_BAD_USAGE };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const chromium = await loadChromium();
|
|
56
|
+
if (!chromium) {
|
|
57
|
+
process.stderr.write(PLAYWRIGHT_INSTALL_HINT);
|
|
58
|
+
return { exitCode: EXIT_BAD_USAGE };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ignorePatterns = (opts.verifyIgnore || []).map((p) => new RegExp(p));
|
|
62
|
+
const timeout = opts.verifyTimeout ?? 10000;
|
|
63
|
+
const allowConsoleError = opts.verifyAllowConsoleError ?? false;
|
|
64
|
+
const events = [];
|
|
65
|
+
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
let browser;
|
|
68
|
+
let loadOk = false;
|
|
69
|
+
let loadFailure = null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
browser = await chromium.launch({ headless: true });
|
|
73
|
+
const ctx = await browser.newContext();
|
|
74
|
+
const page = await ctx.newPage();
|
|
75
|
+
|
|
76
|
+
page.on('pageerror', (err) => {
|
|
77
|
+
events.push({
|
|
78
|
+
type: 'pageerror',
|
|
79
|
+
message: err.message,
|
|
80
|
+
stack: err.stack ?? '',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
page.on('console', (msg) => {
|
|
84
|
+
if (msg.type() !== 'error') return;
|
|
85
|
+
const text = msg.text();
|
|
86
|
+
if (ignorePatterns.some((re) => re.test(text))) return;
|
|
87
|
+
const loc = msg.location();
|
|
88
|
+
events.push({
|
|
89
|
+
type: 'console_error',
|
|
90
|
+
text,
|
|
91
|
+
location: loc?.url ? `${loc.url}:${loc.lineNumber + 1}:${loc.columnNumber + 1}` : '',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
page.on('requestfailed', (req) => {
|
|
95
|
+
const url = req.url();
|
|
96
|
+
if (ignorePatterns.some((re) => re.test(url))) return;
|
|
97
|
+
events.push({
|
|
98
|
+
type: 'request_failed',
|
|
99
|
+
url,
|
|
100
|
+
method: req.method(),
|
|
101
|
+
failure: req.failure()?.errorText ?? '',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
page.on('response', (res) => {
|
|
105
|
+
const status = res.status();
|
|
106
|
+
if (status < 400) return;
|
|
107
|
+
const url = res.url();
|
|
108
|
+
if (ignorePatterns.some((re) => re.test(url))) return;
|
|
109
|
+
events.push({ type: 'response', url, status });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await page.goto(target.url, { timeout, waitUntil: 'networkidle' });
|
|
113
|
+
loadOk = true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
loadFailure = err instanceof Error ? err.message : String(err);
|
|
116
|
+
} finally {
|
|
117
|
+
if (browser) await browser.close().catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const duration_ms = Date.now() - start;
|
|
121
|
+
if (loadFailure) {
|
|
122
|
+
events.push({ type: 'load_failed', message: loadFailure });
|
|
123
|
+
}
|
|
124
|
+
const fatalEvents = events.filter((e) => {
|
|
125
|
+
if (e.type === 'console_error') return !allowConsoleError;
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
let exitCode = EXIT_OK;
|
|
129
|
+
if (!loadOk) exitCode = EXIT_LOAD_FAILED;
|
|
130
|
+
else if (fatalEvents.length > 0) exitCode = EXIT_EVENTS_FOUND;
|
|
131
|
+
|
|
132
|
+
const report = {
|
|
133
|
+
target: target.url,
|
|
134
|
+
status: exitCode === 0 ? 'pass' : 'fail',
|
|
135
|
+
duration_ms,
|
|
136
|
+
events,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (opts.verifyReport) {
|
|
140
|
+
writeFileSync(opts.verifyReport, `${JSON.stringify(report, null, 2)}\n`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (opts.verifyJson) {
|
|
144
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
145
|
+
} else {
|
|
146
|
+
formatHuman(report, allowConsoleError);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { exitCode };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeTarget(raw) {
|
|
153
|
+
if (!raw) return null;
|
|
154
|
+
if (/^https?:\/\//.test(raw) || raw.startsWith('file://')) {
|
|
155
|
+
return { url: raw };
|
|
156
|
+
}
|
|
157
|
+
const abs = isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
|
158
|
+
try {
|
|
159
|
+
const stat = statSync(abs);
|
|
160
|
+
const filePath = stat.isDirectory() ? join(abs, 'index.html') : abs;
|
|
161
|
+
statSync(filePath);
|
|
162
|
+
return { url: pathToFileURL(filePath).href };
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatHuman(report, allowConsoleError) {
|
|
169
|
+
const stream = report.status === 'pass' ? process.stdout : process.stderr;
|
|
170
|
+
stream.write(`zntc verify: ${report.status.toUpperCase()} (${report.duration_ms} ms)\n`);
|
|
171
|
+
stream.write(` target: ${report.target}\n`);
|
|
172
|
+
if (report.events.length === 0) return;
|
|
173
|
+
stream.write(` ${report.events.length} event(s):\n`);
|
|
174
|
+
for (const e of report.events) {
|
|
175
|
+
if (e.type === 'pageerror') {
|
|
176
|
+
stream.write(` [pageerror] ${e.message}\n`);
|
|
177
|
+
} else if (e.type === 'console_error') {
|
|
178
|
+
stream.write(` [console.error] ${e.text}${e.location ? ` @ ${e.location}` : ''}\n`);
|
|
179
|
+
} else if (e.type === 'response') {
|
|
180
|
+
stream.write(` [HTTP ${e.status}] ${e.url}\n`);
|
|
181
|
+
} else if (e.type === 'request_failed') {
|
|
182
|
+
stream.write(` [request_failed] ${e.url} (${e.failure})\n`);
|
|
183
|
+
} else if (e.type === 'load_failed') {
|
|
184
|
+
stream.write(` [load_failed] ${e.message}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (allowConsoleError && report.events.some((e) => e.type === 'console_error')) {
|
|
188
|
+
stream.write(' (--verify-allow-console-error: console errors do not affect exit code)\n');
|
|
189
|
+
}
|
|
190
|
+
}
|