@kaydash9999/banker-plugins 0.23.3 → 0.24.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +311 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +311 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +36 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +540 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +10 -8
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +177 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr.mjs +585 -0
- package/skills/progress-mon/scripts/pm-check.mjs +48 -20
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +2 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +1 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +311 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Argument parsing for build-testbox (detect, run, setup). A bad argument throws UsageError (exit 2).
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { cpus } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export class UsageError extends Error {}
|
|
8
|
+
|
|
9
|
+
export const BROWSERS = ["chromium", "webkit"];
|
|
10
|
+
export const DEFAULT_IMAGES = { rocky8: "docker.io/rockylinux/rockylinux:8", rocky9: "docker.io/rockylinux/rockylinux:9" };
|
|
11
|
+
const OS_NAME = /^[a-z][a-z0-9-]{0,30}$/;
|
|
12
|
+
const IMAGE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/;
|
|
13
|
+
const VM_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
14
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
15
|
+
|
|
16
|
+
const list = (v) => String(v).split(",").map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
|
|
18
|
+
function intIn(flag, v, lo, hi) {
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
if (!Number.isInteger(n) || n < lo || n > hi) throw new UsageError(`${flag} 는 ${lo}~${hi} 정수입니다: ${v}`);
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function oneOf(flag, v, allowed) {
|
|
25
|
+
if (!allowed.includes(v)) throw new UsageError(`${flag} 는 ${allowed.join(", ")} 중 하나입니다: ${v}`);
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function browsersOf(v) {
|
|
30
|
+
const names = list(v);
|
|
31
|
+
for (const n of names) oneOf("--browsers", n, BROWSERS);
|
|
32
|
+
if (!names.length) throw new UsageError("--browsers 가 비었습니다");
|
|
33
|
+
return [...new Set(names)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function osOf(v) {
|
|
37
|
+
if (v === "none") return [];
|
|
38
|
+
const names = list(v);
|
|
39
|
+
for (const n of names) if (!OS_NAME.test(n)) throw new UsageError(`--os 이름은 영문 소문자, 숫자, - 입니다: ${n}`);
|
|
40
|
+
if (!names.length) throw new UsageError("--os 가 비었습니다(컨테이너 묶음을 빼려면 --os none)");
|
|
41
|
+
return [...new Set(names)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function imageOf(o, v) {
|
|
45
|
+
const at = String(v).indexOf("=");
|
|
46
|
+
const name = at > 0 ? v.slice(0, at) : "";
|
|
47
|
+
const image = at > 0 ? v.slice(at + 1) : "";
|
|
48
|
+
if (!OS_NAME.test(name) || !IMAGE.test(image)) throw new UsageError(`--image 는 <os>=<이미지> 입니다: ${v}`);
|
|
49
|
+
o.images[name] = image;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function devicesOf(v) {
|
|
53
|
+
const names = list(v);
|
|
54
|
+
if (!names.length) throw new UsageError("--devices 가 비었습니다");
|
|
55
|
+
if (new Set(names).size !== names.length) throw new UsageError("--devices 에 같은 기기가 둘 이상입니다");
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function httpUrl(target, allowRemote) {
|
|
60
|
+
let url;
|
|
61
|
+
try { url = new URL(target); } catch { throw new UsageError(`URL 이 아닙니다: ${target}`); }
|
|
62
|
+
if (!allowRemote && !LOOPBACK.has(url.hostname)) throw new UsageError(`loopback 이 아닌 주소는 --allow-remote 를 줄 때만 엽니다: ${target}`);
|
|
63
|
+
return url.href;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --url: an http(s) URL (loopback unless --allow-remote) or an existing local file.
|
|
67
|
+
export function toUrl(target, allowRemote = false, cwd = process.cwd()) {
|
|
68
|
+
if (/^https?:\/\//i.test(target)) return httpUrl(target, allowRemote);
|
|
69
|
+
if (/^[\\/]{2}/.test(target)) throw new UsageError(`네트워크 경로는 열지 않습니다: ${target}`);
|
|
70
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]+:/.test(target) && !/^[A-Za-z]:[\\/]/.test(target)) throw new UsageError(`http, https URL 이나 로컬 파일만 엽니다: ${target}`);
|
|
71
|
+
const file = resolve(cwd, target);
|
|
72
|
+
if (!existsSync(file) || !statSync(file).isFile()) throw new UsageError(`파일이 없습니다: ${file}`);
|
|
73
|
+
return pathToFileURL(file).href;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const VALUES = {
|
|
77
|
+
"--url": (o, v) => { o.urlArg = v; },
|
|
78
|
+
"--browsers": (o, v) => { o.browsers = browsersOf(v); o.browsersGiven = true; },
|
|
79
|
+
"--devices": (o, v) => { o.devices = devicesOf(v); },
|
|
80
|
+
"--os": (o, v) => { o.os = osOf(v); },
|
|
81
|
+
"--image": imageOf,
|
|
82
|
+
"--cmd": (o, v) => { if (!String(v).trim()) throw new UsageError("--cmd 가 비었습니다"); o.cmd = v; },
|
|
83
|
+
"--src": (o, v) => { o.src = resolve(v); },
|
|
84
|
+
"--engine": (o, v) => { o.engine = oneOf("--engine", v, ["auto", "docker", "podman"]); },
|
|
85
|
+
"--network": (o, v) => { o.network = oneOf("--network", v, ["bridge", "none", "host"]); },
|
|
86
|
+
"--jobs": (o, v) => { o.jobs = intIn("--jobs", v, 1, 64); },
|
|
87
|
+
"--timeout": (o, v) => { o.timeout = intIn("--timeout", v, 1, 86400); },
|
|
88
|
+
"--out": (o, v) => { o.out = resolve(v); },
|
|
89
|
+
"--tart-vm": (o, v) => { if (!VM_NAME.test(v)) throw new UsageError(`--tart-vm 이름이 맞지 않습니다: ${v}`); o.tartVm = v; },
|
|
90
|
+
"--images": (o, v) => { o.setupImages = osOf(v); },
|
|
91
|
+
"--playwright-dir": (o, v) => { if (!String(v).trim()) throw new UsageError("--playwright-dir 가 비었습니다"); o.playwrightDir = resolve(v); },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const SWITCHES = {
|
|
95
|
+
"--json": "json", "--yes": "yes", "--browser-in-container": "browserInContainer", "--playwright-image": "playwrightImage",
|
|
96
|
+
"--playwright-from-project": "fromProject", "--allow-remote": "allowRemote",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const ALLOWED = {
|
|
100
|
+
detect: ["--json", "--engine", "--image", "--os", "--playwright-from-project", "--playwright-dir"],
|
|
101
|
+
run: ["--url", "--browsers", "--devices", "--browser-in-container", "--os", "--image", "--cmd", "--src", "--engine",
|
|
102
|
+
"--network", "--jobs", "--timeout", "--out", "--playwright-from-project", "--playwright-dir", "--allow-remote", "--tart-vm", "--json"],
|
|
103
|
+
setup: ["--images", "--browsers", "--playwright-image", "--yes", "--engine", "--image", "--playwright-from-project", "--playwright-dir", "--json"],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function defaults(command) {
|
|
107
|
+
return {
|
|
108
|
+
command, json: false, yes: false, browserInContainer: false, playwrightImage: false, fromProject: false, playwrightDir: null, allowRemote: false,
|
|
109
|
+
urlArg: null, url: null, browsers: [...BROWSERS], browsersGiven: false, devices: null, os: ["rocky8", "rocky9"], images: {},
|
|
110
|
+
cmd: "cat /etc/os-release", src: process.cwd(), engine: "auto", network: null,
|
|
111
|
+
jobs: Math.max(1, Math.min(4, cpus().length || 1)), timeout: 300, out: null, tartVm: null, setupImages: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readFlags(command, argv) {
|
|
116
|
+
const o = defaults(command);
|
|
117
|
+
for (let i = 0; i < argv.length; i++) {
|
|
118
|
+
const a = argv[i];
|
|
119
|
+
if (!ALLOWED[command].includes(a)) throw new UsageError(`${command} 에 쓸 수 없는 인자입니다: ${a}`);
|
|
120
|
+
if (SWITCHES[a]) { o[SWITCHES[a]] = true; continue; }
|
|
121
|
+
if (argv[i + 1] === undefined) throw new UsageError(`${a} 뒤에 값이 필요합니다`);
|
|
122
|
+
VALUES[a](o, argv[++i]);
|
|
123
|
+
}
|
|
124
|
+
return o;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const imageFor = (o, os) => o.images[os] || DEFAULT_IMAGES[os] || null;
|
|
128
|
+
|
|
129
|
+
function validateBrowserArgs(o) {
|
|
130
|
+
const browserOnly = o.devices || o.browsersGiven || o.browserInContainer;
|
|
131
|
+
if (!o.urlArg && browserOnly) throw new UsageError("--devices, --browsers, --browser-in-container 는 --url 과 함께 씁니다");
|
|
132
|
+
if (o.urlArg) o.url = toUrl(o.urlArg, o.allowRemote);
|
|
133
|
+
if (o.browserInContainer && !/^https?:/.test(o.url)) throw new UsageError("--browser-in-container 는 http, https URL 만 엽니다");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateRun(o) {
|
|
137
|
+
validateBrowserArgs(o);
|
|
138
|
+
const noImage = o.os.find((os) => os !== "macos" && !imageFor(o, os));
|
|
139
|
+
if (noImage) throw new UsageError(`--os ${noImage} 의 이미지를 --image ${noImage}=<이미지> 로 주십시오`);
|
|
140
|
+
if (!o.url && !o.os.length) throw new UsageError("돌릴 칸이 없습니다(--url 과 --os 가 모두 비었음)");
|
|
141
|
+
return o;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateSetup(o) {
|
|
145
|
+
for (const os of o.setupImages || []) if (os === "macos" || !imageFor(o, os)) throw new UsageError(`--images ${os} 는 받을 이미지가 없습니다(macOS 는 Tart 로 따로)`);
|
|
146
|
+
return o;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseArgs(argv) {
|
|
150
|
+
const [command, ...rest] = argv;
|
|
151
|
+
if (!ALLOWED[command]) throw new UsageError(`하위 명령은 detect, run, setup 중 하나입니다: ${command ?? "(없음)"}`);
|
|
152
|
+
const o = readFlags(command, rest);
|
|
153
|
+
if (command === "run") return validateRun(o);
|
|
154
|
+
if (command === "setup") return validateSetup(o);
|
|
155
|
+
return o;
|
|
156
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Browser cells of build-testbox. Runs on the host (a child of testbox.mjs) or inside the Playwright image
|
|
3
|
+
// (--browser-in-container), so it imports no other file of this skill. Each browser type starts once; each device
|
|
4
|
+
// gets its own context. A cell passes when the response is 2xx or 3xx (a file URL has none) and the page threw nothing.
|
|
5
|
+
//
|
|
6
|
+
// node browser-cell.mjs --playwright <playwright entry file> --spec <spec.json>
|
|
7
|
+
//
|
|
8
|
+
// spec: { url, jobs, timeoutMs, settleMs, outDir, cells: [{ id, device, browser, png }] }
|
|
9
|
+
// Prints one line "BANKER_TESTBOX_RESULT <json>": { cells: [...] } or { fatal, detail }.
|
|
10
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
|
|
14
|
+
export const RESULT_TAG = "BANKER_TESTBOX_RESULT ";
|
|
15
|
+
|
|
16
|
+
const firstLine = (e) => String((e && e.message) || e || "").split(/\r?\n/).map((l) => l.trim()).find(Boolean) || "";
|
|
17
|
+
|
|
18
|
+
export function classifyLaunchError(message) {
|
|
19
|
+
const m = String(message || "");
|
|
20
|
+
if (/Executable doesn't exist/i.test(m)) return "needs-browser";
|
|
21
|
+
if (/missing dependencies|error while loading shared libraries|cannot open shared object file/i.test(m)) return "host-deps";
|
|
22
|
+
if (/\bEPERM\b|\bEACCES\b|Operation not permitted|Permission denied/i.test(m)) return "sandbox";
|
|
23
|
+
if (/Timeout \d+ms exceeded|timed out/i.test(m)) return "timeout";
|
|
24
|
+
return "launch-failed";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// chromium: Playwright's headless shell first, then the full Chromium (channel "chromium"); webkit has one build.
|
|
28
|
+
const VARIANTS = {
|
|
29
|
+
chromium: [["headless-shell", {}], ["chromium", { channel: "chromium" }]],
|
|
30
|
+
webkit: [["webkit", {}]],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function tryLaunch(pw, name, options, timeoutMs) {
|
|
34
|
+
try {
|
|
35
|
+
return { browser: await pw[name].launch({ ...options, timeout: timeoutMs }) };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
const message = String((e && e.message) || e);
|
|
38
|
+
return { reason: classifyLaunchError(message), detail: firstLine(message).slice(0, 300) };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The reason to report when no variant starts: a missing executable says less than a missing library.
|
|
43
|
+
// No variant at all means the browser name is unknown.
|
|
44
|
+
function worstReason(failures, name) {
|
|
45
|
+
if (!failures.length) return { reason: "launch-failed", detail: `모르는 브라우저 ${name}` };
|
|
46
|
+
const telling = failures.find((f) => f.reason !== "needs-browser");
|
|
47
|
+
return telling || failures[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// { browser, variant } or { reason, detail }.
|
|
51
|
+
async function launchBest(pw, name, timeoutMs = 30000) {
|
|
52
|
+
const failures = [];
|
|
53
|
+
for (const [variant, options] of VARIANTS[name] || []) {
|
|
54
|
+
const r = await tryLaunch(pw, name, options, timeoutMs);
|
|
55
|
+
if (r.browser) return { browser: r.browser, variant };
|
|
56
|
+
failures.push({ ...r, variant });
|
|
57
|
+
}
|
|
58
|
+
return worstReason(failures, name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// detect: each variant started and closed. { runnable, variant, reason, detail, variants: { <v>: { ok, reason?, detail? } } }
|
|
62
|
+
export async function probeBrowser(pw, name, timeoutMs = 30000) {
|
|
63
|
+
const variants = {};
|
|
64
|
+
for (const [variant, options] of VARIANTS[name] || []) {
|
|
65
|
+
const r = await tryLaunch(pw, name, options, timeoutMs);
|
|
66
|
+
if (r.browser) await r.browser.close().catch(() => {});
|
|
67
|
+
variants[variant] = r.browser ? { ok: true } : { ok: false, reason: r.reason, detail: r.detail };
|
|
68
|
+
}
|
|
69
|
+
const ok = Object.entries(variants).find(([, v]) => v.ok);
|
|
70
|
+
if (ok) return { runnable: true, variant: ok[0], variants };
|
|
71
|
+
const worst = worstReason(Object.values(variants), name);
|
|
72
|
+
return { runnable: false, reason: worst.reason, detail: worst.detail, variants };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function judge(httpStatus, pageErrors) {
|
|
76
|
+
if (httpStatus !== null && (httpStatus < 200 || httpStatus >= 400)) return ["fail", `http-${httpStatus}`];
|
|
77
|
+
if (pageErrors > 0) return ["fail", "page-error"];
|
|
78
|
+
return ["pass", ""];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function visit(browser, pw, cell, spec) {
|
|
82
|
+
const context = await browser.newContext({ ...pw.devices[cell.device] });
|
|
83
|
+
const pageErrors = [];
|
|
84
|
+
const consoleErrors = [];
|
|
85
|
+
try {
|
|
86
|
+
const page = await context.newPage();
|
|
87
|
+
page.on("pageerror", (e) => pageErrors.push(firstLine(e).slice(0, 300)));
|
|
88
|
+
page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text().slice(0, 300)); });
|
|
89
|
+
const response = await page.goto(spec.url, { waitUntil: "load", timeout: spec.timeoutMs });
|
|
90
|
+
if (spec.settleMs) await page.waitForTimeout(spec.settleMs);
|
|
91
|
+
const title = await page.title();
|
|
92
|
+
await page.screenshot({ path: join(spec.outDir, cell.png) });
|
|
93
|
+
const httpStatus = response ? response.status() : null;
|
|
94
|
+
const [status, reason] = judge(httpStatus, pageErrors.length);
|
|
95
|
+
return { status, reason, httpStatus, title, pageErrors, consoleErrors, screenshot: cell.png };
|
|
96
|
+
} finally {
|
|
97
|
+
await context.close().catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runCell(browser, pw, cell, spec, variant) {
|
|
102
|
+
const startedAt = new Date().toISOString();
|
|
103
|
+
let timer;
|
|
104
|
+
const limit = new Promise((r) => { timer = setTimeout(() => r({ status: "unverified", reason: "timeout" }), spec.timeoutMs + 5000); });
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
result = await Promise.race([visit(browser, pw, cell, spec), limit]);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const timeout = /Timeout \d+ms exceeded/i.test(String(e && e.message));
|
|
110
|
+
result = { status: timeout ? "unverified" : "fail", reason: timeout ? "timeout" : "page-error", detail: firstLine(e).slice(0, 300) };
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
return { id: cell.id, variant, ...result, startedAt, endedAt: new Date().toISOString() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function pool(items, jobs, fn) {
|
|
118
|
+
const results = new Array(items.length);
|
|
119
|
+
let next = 0;
|
|
120
|
+
const worker = async () => {
|
|
121
|
+
while (next < items.length) {
|
|
122
|
+
const i = next++;
|
|
123
|
+
results[i] = await fn(items[i]);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(jobs, items.length)) }, worker));
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runBrowser(pw, name, cells, spec) {
|
|
131
|
+
const startedAt = new Date().toISOString();
|
|
132
|
+
const launched = await launchBest(pw, name, Math.min(spec.timeoutMs, 60000));
|
|
133
|
+
if (!launched.browser) {
|
|
134
|
+
const endedAt = new Date().toISOString();
|
|
135
|
+
return cells.map((c) => ({ id: c.id, status: "unverified", reason: launched.reason, detail: launched.detail, startedAt, endedAt }));
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return await pool(cells, spec.jobs || 1, (c) => runCell(launched.browser, pw, c, spec, launched.variant));
|
|
139
|
+
} finally {
|
|
140
|
+
await launched.browser.close().catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// All browser types at once; the cells of one type share its jobs.
|
|
145
|
+
async function runGroup(pw, spec) {
|
|
146
|
+
mkdirSync(spec.outDir, { recursive: true });
|
|
147
|
+
const byBrowser = new Map();
|
|
148
|
+
for (const c of spec.cells) byBrowser.set(c.browser, [...(byBrowser.get(c.browser) || []), c]);
|
|
149
|
+
const groups = await Promise.all([...byBrowser].map(([name, cells]) => runBrowser(pw, name, cells, spec)));
|
|
150
|
+
return groups.flat();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function importPlaywright(entry) {
|
|
154
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
155
|
+
return mod.chromium ? mod : mod.default;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function argOf(argv, flag) {
|
|
159
|
+
const i = argv.indexOf(flag);
|
|
160
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function main(argv) {
|
|
164
|
+
const entry = argOf(argv, "--playwright");
|
|
165
|
+
const specFile = argOf(argv, "--spec");
|
|
166
|
+
let pw;
|
|
167
|
+
try {
|
|
168
|
+
pw = await importPlaywright(entry);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return { fatal: "needs-playwright", detail: firstLine(e) };
|
|
171
|
+
}
|
|
172
|
+
const spec = JSON.parse(readFileSync(specFile, "utf8"));
|
|
173
|
+
return { cells: await runGroup(pw, spec) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
177
|
+
if (isMain) {
|
|
178
|
+
main(process.argv.slice(2))
|
|
179
|
+
.then((r) => { process.stdout.write(`${RESULT_TAG}${JSON.stringify(r)}\n`); process.exit(0); })
|
|
180
|
+
.catch((e) => { process.stdout.write(`${RESULT_TAG}${JSON.stringify({ fatal: "launch-failed", detail: firstLine(e) })}\n`); process.exit(0); });
|
|
181
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The browser group of build-testbox: plans one cell per device and runs browser-cell.mjs on the host (a child
|
|
2
|
+
// process) or inside the Playwright image (--browser-in-container). Downloads nothing.
|
|
3
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { BROWSERS, UsageError } from "./args.mjs";
|
|
8
|
+
import { RESULT_TAG } from "./browser-cell.mjs";
|
|
9
|
+
import { NAME_PREFIX, engineReason, imageState, randomTag, rmArgs } from "./containers.mjs";
|
|
10
|
+
import { playwrightImage } from "./detect.mjs";
|
|
11
|
+
import { firstLine, runProc } from "./proc.mjs";
|
|
12
|
+
import { cellOf } from "./report.mjs";
|
|
13
|
+
|
|
14
|
+
const CELL_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "browser-cell.mjs");
|
|
15
|
+
export const DEFAULT_DEVICES = ["Desktop Chrome", "Pixel 7", "Desktop Safari", "iPhone 15"];
|
|
16
|
+
const KNOWN = { "Desktop Chrome": "chromium", "Pixel 7": "chromium", "Desktop Safari": "webkit", "iPhone 15": "webkit" };
|
|
17
|
+
const iso = () => new Date().toISOString();
|
|
18
|
+
|
|
19
|
+
export const slug = (device) => device.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "device";
|
|
20
|
+
|
|
21
|
+
function cellBase(device, browser) {
|
|
22
|
+
return { id: `browser:${device}`, group: "browser", target: device, device, browser, png: `browser-${slug(device)}.png` };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One cell per device; the browser is the device's defaultBrowserType. devices is playwright.devices, or null when
|
|
26
|
+
// Playwright is missing (then the four default names map by a fixed table and every cell is needs-playwright).
|
|
27
|
+
// A cell with a preset reason does not run.
|
|
28
|
+
function deviceCell(o, name, devices) {
|
|
29
|
+
if (!devices) {
|
|
30
|
+
const browser = KNOWN[name] || "?";
|
|
31
|
+
return browser !== "?" && !o.browsers.includes(browser) ? null : { ...cellBase(name, browser), preset: "needs-playwright" };
|
|
32
|
+
}
|
|
33
|
+
const d = devices[name];
|
|
34
|
+
if (!d) return { ...cellBase(name, KNOWN[name] || "?"), preset: "unknown-device" };
|
|
35
|
+
const browser = d.defaultBrowserType;
|
|
36
|
+
if (!BROWSERS.includes(browser)) throw new UsageError(`기기 ${name} 의 브라우저 ${browser} 는 지원하지 않습니다(chromium, webkit 만)`);
|
|
37
|
+
return o.browsers.includes(browser) ? cellBase(name, browser) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planBrowserCells(o, devices) {
|
|
41
|
+
const cells = (o.devices || DEFAULT_DEVICES).map((name) => deviceCell(o, name, devices)).filter(Boolean);
|
|
42
|
+
if (!cells.length) throw new UsageError("--browsers 로 거르고 나니 브라우저 칸이 없습니다");
|
|
43
|
+
return cells;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const strip = ({ preset, ...rest }) => rest;
|
|
47
|
+
|
|
48
|
+
function presetCell(c, detail) {
|
|
49
|
+
return cellOf({ ...strip(c), startedAt: iso() }, "unverified", c.preset, detail ? { detail } : {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseResult(text) {
|
|
53
|
+
const line = String(text).split(/\r?\n/).reverse().find((l) => l.startsWith(RESULT_TAG));
|
|
54
|
+
if (!line) return null;
|
|
55
|
+
try { return JSON.parse(line.slice(RESULT_TAG.length)); } catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Child results joined to the planned cells. A cell the child never reported is timeout (child killed) or
|
|
59
|
+
// launch-failed.
|
|
60
|
+
export function mergeResults(cells, r, extra) {
|
|
61
|
+
const parsed = parseResult(r.stdout);
|
|
62
|
+
const byId = new Map(((parsed && parsed.cells) || []).map((c) => [c.id, c]));
|
|
63
|
+
return cells.map((c) => {
|
|
64
|
+
const got = byId.get(c.id);
|
|
65
|
+
const base = { ...strip(c), ...extra };
|
|
66
|
+
if (got) return cellOf({ ...base, ...got, screenshot: got.screenshot ? join(extra.out, got.screenshot) : undefined }, got.status, got.reason);
|
|
67
|
+
if (parsed && parsed.fatal) return cellOf({ ...base, startedAt: iso() }, "unverified", parsed.fatal, { detail: parsed.detail });
|
|
68
|
+
return cellOf({ ...base, startedAt: iso() }, "unverified", r.timedOut ? "timeout" : "launch-failed", { detail: (r.stderr || r.error || "").slice(0, 300) });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const groupLimit = (o, n) => (o.timeout * Math.ceil(n / o.jobs) + 90) * 1000;
|
|
73
|
+
|
|
74
|
+
function specOf(o, cells, outDir, url) {
|
|
75
|
+
return { url, jobs: o.jobs, timeoutMs: o.timeout * 1000, settleMs: 300, outDir, cells: cells.map(({ id, device, browser, png }) => ({ id, device, browser, png })) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runOnHost(ctx, cells) {
|
|
79
|
+
const { o, out } = ctx;
|
|
80
|
+
const specFile = join(out, "browser-spec.json");
|
|
81
|
+
writeFileSync(specFile, JSON.stringify(specOf(o, cells, out, o.url)));
|
|
82
|
+
const r = await runProc(process.execPath, [CELL_SCRIPT, "--playwright", ctx.pwInfo.entry, "--spec", specFile], { cwd: out, timeoutMs: groupLimit(o, cells.length) });
|
|
83
|
+
const log = join(out, "browser.log");
|
|
84
|
+
writeFileSync(log, r.out);
|
|
85
|
+
return mergeResults(cells, r, { out, log, where: "host" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Inside a container the host's loopback is the container's on Linux with --network host; elsewhere the engine's
|
|
89
|
+
// host name stands in for it.
|
|
90
|
+
export function containerUrl(url, engine, platform = process.platform) {
|
|
91
|
+
if (platform === "linux") return url;
|
|
92
|
+
const u = new URL(url);
|
|
93
|
+
if (["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) u.hostname = engine.kind === "podman" ? "host.containers.internal" : "host.docker.internal";
|
|
94
|
+
return u.href;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stageRunner(o, cells, url) {
|
|
98
|
+
const dir = mkdtempSync(join(tmpdir(), `${NAME_PREFIX}-pw-`));
|
|
99
|
+
mkdirSync(join(dir, "node_modules"));
|
|
100
|
+
copyFileSync(CELL_SCRIPT, join(dir, "browser-cell.mjs"));
|
|
101
|
+
writeFileSync(join(dir, "spec.json"), JSON.stringify(specOf(o, cells, "/tb/out", url)));
|
|
102
|
+
return dir;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The cp steps: the runner and spec, then the host's playwright package and the playwright-core it depends on.
|
|
106
|
+
export function copySteps(stageDir, pwInfo, name) {
|
|
107
|
+
return [
|
|
108
|
+
["cp", `${stageDir}${sep}.`, `${name}:/tb`],
|
|
109
|
+
["cp", pwInfo.dir, `${name}:/tb/node_modules/playwright`],
|
|
110
|
+
["cp", pwInfo.coreDir, `${name}:/tb/node_modules/playwright-core`],
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function containerSteps(ctx, cells, name, stageDir) {
|
|
115
|
+
const { o, engine, out } = ctx;
|
|
116
|
+
const linux = process.platform === "linux";
|
|
117
|
+
const runner = ["node", "/tb/browser-cell.mjs", "--playwright", "/tb/node_modules/playwright/index.mjs", "--spec", "/tb/spec.json"];
|
|
118
|
+
const create = ["create", "--name", name, "--ipc=host", ...(linux ? ["--network", "host"] : []), ctx.image, ...runner];
|
|
119
|
+
for (const args of [create, ...copySteps(stageDir, ctx.pwInfo, name)]) {
|
|
120
|
+
const r = await runProc(engine.bin, args, { timeoutMs: 600000 });
|
|
121
|
+
if (r.code !== 0) return { failed: r };
|
|
122
|
+
}
|
|
123
|
+
const r = await runProc(engine.bin, ["start", "-a", name], { timeoutMs: groupLimit(o, cells.length) });
|
|
124
|
+
await runProc(engine.bin, ["cp", `${name}:/tb/out/.`, out], { timeoutMs: 600000 });
|
|
125
|
+
return { r, network: linux ? "host" : "default" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runInContainer(ctx, cells) {
|
|
129
|
+
const { engine, out } = ctx;
|
|
130
|
+
if (!engine) return cells.map((c) => presetCell({ ...c, preset: "needs-engine" }));
|
|
131
|
+
ctx.image = ctx.o.images.playwright || playwrightImage(ctx.pwInfo.version);
|
|
132
|
+
const img = await imageState(engine, ctx.image);
|
|
133
|
+
if (!img.present) return cells.map((c) => presetCell({ ...c, preset: img.reason, image: ctx.image }, img.detail));
|
|
134
|
+
const name = `${NAME_PREFIX}-${randomTag()}-browser`;
|
|
135
|
+
const stageDir = stageRunner(ctx.o, cells, containerUrl(ctx.o.url, engine));
|
|
136
|
+
ctx.tracker.containers.add(name);
|
|
137
|
+
ctx.tracker.temps.add(stageDir);
|
|
138
|
+
try {
|
|
139
|
+
const s = await containerSteps(ctx, cells, name, stageDir);
|
|
140
|
+
const log = join(out, "browser.log");
|
|
141
|
+
writeFileSync(log, (s.failed || s.r).out);
|
|
142
|
+
if (s.failed) {
|
|
143
|
+
const reason = engineReason(`${s.failed.out}\n${s.failed.error || ""}`) || "launch-failed";
|
|
144
|
+
return cells.map((c) => presetCell({ ...c, preset: reason, image: ctx.image, log }, firstLine(s.failed.stderr) || s.failed.error));
|
|
145
|
+
}
|
|
146
|
+
return mergeResults(cells, s.r, { out, log, where: "container", image: ctx.image, network: s.network, engine: engine.name });
|
|
147
|
+
} finally {
|
|
148
|
+
await runProc(engine.bin, rmArgs(engine, name), { timeoutMs: 60000 });
|
|
149
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
150
|
+
ctx.tracker.containers.delete(name);
|
|
151
|
+
ctx.tracker.temps.delete(stageDir);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ctx: { o, out, engine, tracker, pw, pwInfo, pwError }
|
|
156
|
+
export async function runBrowserGroup(ctx) {
|
|
157
|
+
const cells = planBrowserCells(ctx.o, ctx.pw ? ctx.pw.devices : null);
|
|
158
|
+
const preset = cells.filter((c) => c.preset).map((c) => presetCell(c, c.preset === "needs-playwright" ? ctx.pwError : null));
|
|
159
|
+
const live = cells.filter((c) => !c.preset);
|
|
160
|
+
if (!live.length) return preset;
|
|
161
|
+
const ran = ctx.o.browserInContainer ? await runInContainer(ctx, live) : await runOnHost(ctx, live);
|
|
162
|
+
const order = new Map(cells.map((c, i) => [c.id, i]));
|
|
163
|
+
return [...preset, ...ran].sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
164
|
+
}
|