@kaydash9999/banker-plugins 0.23.2 → 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 +56 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/skills/teamwork/SKILL.md +19 -6
- package/codex/skills/teamwork/scripts/teamwork.mjs +87 -22
- 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 +11 -9
- 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 +124 -29
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +82 -30
- 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,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-testbox
|
|
3
|
+
description: "이 PC 가 스스로 구성하는 범용 시험 환경. 서비스 시험용 브라우저 행렬(Playwright Chromium 과 WebKit, 데스크톱과 모바일 기기 에뮬레이션)과 배포 시험용 컨테이너 OS(Rocky Linux 8, 9. macOS 는 Apple Silicon 의 Tart VM 만)를 병렬로 실행. run 은 내려받지 않고 못 돈 칸은 unverified 와 사유로 끝남. 받기는 확인 뒤 setup --yes 로만. 'build-testbox'/'테스트박스 구성'/'멀티 OS 브라우저 시험'/'컨테이너 OS 시험'/'사파리 크롬 모바일 시험' 시 사용."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# build-testbox: 브라우저 행렬과 컨테이너 OS 를 병렬로 돌리는 시험 환경
|
|
7
|
+
|
|
8
|
+
이 스킬을 쓰는 PC, 서버, 세션이 자기 자원으로 시험 환경을 꾸린다. 특정 테스트박스를 전제하지 않는다.
|
|
9
|
+
스크립트는 순수 Node 18+ 이고 의존성이 없다. Playwright 는 이 저장소의 의존성이 아니라 설치된 것을 찾아 불러온다. 찾는 순서는 아래와 같고, 앞의 것이 있으면 뒤는 보지 않는다.
|
|
10
|
+
|
|
11
|
+
1. `--playwright-dir <폴더>`: `node_modules/playwright` 를 담은 폴더
|
|
12
|
+
2. 환경 변수 `BUILD_TESTBOX_PLAYWRIGHT_DIR`: 1 과 같은 뜻
|
|
13
|
+
3. `--playwright-from-project` 일 때 작업 폴더의 프로젝트 설치(그 프로젝트 코드가 돎)
|
|
14
|
+
4. 전역 npm root
|
|
15
|
+
|
|
16
|
+
1 이나 2 로 준 폴더에 Playwright 가 없으면 3, 4 로 넘어가지 않고 그 폴더를 적어 "없음" 으로 보고한다.
|
|
17
|
+
답변은 한글.
|
|
18
|
+
|
|
19
|
+
| 묶음 | 칸 | 엔진 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| 브라우저(서비스 시험) | 기기 하나가 칸 하나. 기본 `Desktop Chrome`, `Pixel 7`(chromium), `Desktop Safari`, `iPhone 15`(webkit) | 호스트의 Playwright, 또는 `--browser-in-container` 로 Playwright 공식 이미지 |
|
|
22
|
+
| 컨테이너 OS(배포 시험) | OS 하나가 칸 하나. 기본 `rocky8`, `rocky9` | docker 또는 podman |
|
|
23
|
+
| macOS | `--os macos` | Apple Silicon Mac 의 Tart VM |
|
|
24
|
+
|
|
25
|
+
- 두 묶음은 같은 순간에 시작한다. 묶음 안에서도 칸마다 `--jobs` 개까지 동시에 돈다
|
|
26
|
+
- Safari 는 WebKit 에뮬레이션, 모바일은 Playwright 기기 에뮬레이션이다. 실제 Safari, iOS 시뮬레이터는 범위 밖이다
|
|
27
|
+
|
|
28
|
+
## 언제 쓰나
|
|
29
|
+
|
|
30
|
+
- 로컬 서비스(127.0.0.1)를 Chrome 과 Safari 계열, PC 와 모바일 화면으로 한 번에 열어 보고 싶을 때
|
|
31
|
+
- 배포 스크립트나 설치 명령을 Rocky Linux 8, 9 컨테이너에서 같이 돌려 보고 싶을 때
|
|
32
|
+
- airgap 에서도 "무엇이 돌았고 무엇이 왜 못 돌았는지" 를 거짓 통과 없이 남기고 싶을 때
|
|
33
|
+
|
|
34
|
+
## 쓰지 않을 때
|
|
35
|
+
|
|
36
|
+
- Playwright 자체의 설치와 OS 라이브러리, Xvfb 준비. setup-playwright 를 쓴다
|
|
37
|
+
- 등록된 SSH 테스트박스에서 저장소 시험을 돌릴 때. remains 를 쓴다
|
|
38
|
+
- 비 Apple 호스트에서 macOS 를 띄우려 할 때. 라이선스상 하지 않는다(아래 "macOS" 절)
|
|
39
|
+
|
|
40
|
+
## setup-playwright 와 역할 나눔
|
|
41
|
+
|
|
42
|
+
| 일 | 맡는 스킬 |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Playwright 패키지 설치, OS 라이브러리(dnf, apt), Xvfb, `PLAYWRIGHT_BROWSERS_PATH` | setup-playwright |
|
|
45
|
+
| 이미 있는 Playwright 로 브라우저 빌드 받기(`npx --no-install playwright install <이름>`) | build-testbox `setup` |
|
|
46
|
+
| 컨테이너 이미지 받기(Rocky, Playwright 공식 이미지) | build-testbox `setup` |
|
|
47
|
+
| 브라우저 행렬과 컨테이너 OS 실행, 판정, 결과 기록 | build-testbox `run` |
|
|
48
|
+
|
|
49
|
+
- Playwright 패키지가 없으면 브라우저 칸은 `unverified(needs-playwright)` 이고 안내는 setup-playwright 다
|
|
50
|
+
|
|
51
|
+
## 자리 표시
|
|
52
|
+
|
|
53
|
+
| 자리 | 뜻 |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `<S>` | 이 스킬 디렉터리 절대경로(아래 런타임 표) |
|
|
56
|
+
| `<out>` | 결과 폴더. 기본 `./.testbox/<UTC 시각>` |
|
|
57
|
+
|
|
58
|
+
- 명령은 `node "<S>/scripts/testbox.mjs" <detect|run|setup> [인자]` 꼴로 실행한다
|
|
59
|
+
- 셸 변수는 도구 호출 사이에 남지 않는다. 명령마다 실제 경로를 적는다
|
|
60
|
+
|
|
61
|
+
## 런타임 표
|
|
62
|
+
|
|
63
|
+
| 항목 | Claude Code | Codex |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| 스킬 디렉터리 | 스킬 맨 위 "Base directory for this skill:" 경로 | Codex 스킬 목록이 보이는 이 SKILL.md 경로의 폴더. npm 설치는 `~/.codex/skills/banker-build-testbox/`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/build-testbox/`. 절대경로로 씀 |
|
|
66
|
+
| `detect` | 바로 실행 | 엔진 소켓과 브라우저 실행 때문에 승인 요청(아래 승인 표) |
|
|
67
|
+
| `run` | 바로 실행 | 승인 요청 |
|
|
68
|
+
| `setup` 확인 | `AskUserQuestion` | `request_user_input` |
|
|
69
|
+
| `setup --yes` | 확인 뒤 실행 | 확인 뒤 네트워크 승인 요청 |
|
|
70
|
+
|
|
71
|
+
## 하위 명령
|
|
72
|
+
|
|
73
|
+
| 명령 | 내용 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `detect [--json] [--engine ...] [--os ...] [--image <os>=<이미지>] [--playwright-from-project] [--playwright-dir <폴더>]` | 호스트 OS, 아키텍처, Apple Silicon 여부, Playwright 위치와 판, 브라우저별 실제 headless 실행 결과(chromium 은 headless shell 과 전체 chromium 을 따로), 엔진(docker 가 podman 에뮬레이션인지), 로컬 이미지 유무(`image inspect`), Tart 와 VM 목록. 내려받지 않음 |
|
|
76
|
+
| `run [옵션]` | 브라우저 묶음과 컨테이너 묶음을 병렬 실행. 내려받지 않음 |
|
|
77
|
+
| `setup [--images <os,...>] [--browsers <이름,...>] [--playwright-image] [--yes]` | `--yes` 가 없으면 받을 것과 대략 크기만 보이고 종료 0. 이름을 주지 않으면 로컬에 없는 기본 이미지와 빌드가 없는 브라우저가 대상 |
|
|
78
|
+
|
|
79
|
+
`run` 옵션
|
|
80
|
+
|
|
81
|
+
| 옵션 | 뜻 | 기본 |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `--url <loopback URL\|파일>` | 브라우저 묶음 대상. 없으면 브라우저 묶음은 요청 안 됨 | 없음 |
|
|
84
|
+
| `--browsers chromium,webkit` | 브라우저 거름 | 둘 다 |
|
|
85
|
+
| `--devices "<이름>,..."` | Playwright 기기 이름. 기기의 `defaultBrowserType` 으로 돎 | 위 4개 |
|
|
86
|
+
| `--browser-in-container` | 브라우저 칸을 `mcr.microsoft.com/playwright:v<호스트 Playwright 판>-noble` 안에서 돌림. http, https URL 만 | 꺼짐 |
|
|
87
|
+
| `--os rocky8,rocky9,macos` | 컨테이너와 VM 대상. `none` 이면 컨테이너 묶음을 빼고, 다른 이름은 `--image` 와 함께 | `rocky8,rocky9` |
|
|
88
|
+
| `--image <os>=<이미지>` | 이미지 바꾸기. `playwright=<이미지>` 는 `--browser-in-container` 이미지 | rocky8 `docker.io/rockylinux/rockylinux:8`, rocky9 `:9` |
|
|
89
|
+
| `--cmd "<명령>"` | 컨테이너 안 `/src` 에서 `sh -c` 로 실행 | `cat /etc/os-release` |
|
|
90
|
+
| `--src <dir>` | 컨테이너로 넣을 소스 | 작업 폴더 |
|
|
91
|
+
| `--engine auto\|docker\|podman` | `auto` 는 실제 docker, podman, podman 에뮬레이션 docker 순 | `auto` |
|
|
92
|
+
| `--network bridge\|none\|host` | 컨테이너 네트워크 | 엔진 기본 |
|
|
93
|
+
| `--jobs N`, `--timeout <초>` | 묶음마다 동시 칸 수, 칸마다 시간 제한 | `min(4, CPU 수)`, 300 |
|
|
94
|
+
| `--out <dir>` | 결과 폴더 | `./.testbox/<시각>` |
|
|
95
|
+
| `--playwright-from-project` | 프로젝트 `node_modules` 의 Playwright(그 프로젝트 코드가 돎) | 꺼짐 |
|
|
96
|
+
| `--playwright-dir <폴더>` | 이 폴더의 `node_modules/playwright` 를 씀. `detect`, `setup` 에도 씀. 환경 변수 `BUILD_TESTBOX_PLAYWRIGHT_DIR` 보다 앞 | 없음 |
|
|
97
|
+
| `--allow-remote` | loopback 이 아닌 URL 도 엶 | 꺼짐 |
|
|
98
|
+
| `--tart-vm <이름>`, `--json` | Tart VM 이름(없으면 첫 로컬 VM), 결과 JSON 한 줄 출력 | 꺼짐 |
|
|
99
|
+
|
|
100
|
+
- 배포 시험 명령은 사용자가 준다. Rocky 기본 이미지에는 node, npm 이 없다
|
|
101
|
+
- 온라인 예: `--cmd "dnf -y install nodejs && node -v"`(bridge 네트워크와 저장소 미러 필요)
|
|
102
|
+
- 오프라인 예: `--cmd "cat /etc/os-release && rpm -q bash"`
|
|
103
|
+
- 컨테이너 칸: `image inspect`(받지 않음), `create`, 소스 사본을 `cp` 로 `/src` 에, `start -a`, 끝나면 `rm -f`. 바인드 마운트는 쓰지 않는다(SELinux 재라벨, Windows 경로 문제)
|
|
104
|
+
- 소스 사본: git 저장소면 추적 파일과 무시되지 않은 새 파일(작업 트리 내용), 아니면 `node_modules`, `.git`, `.testbox` 를 뺀 전부. 결과 폴더는 뺀다
|
|
105
|
+
- 이름은 `banker-testbox-<난수>-<os>`. 시간 초과, SIGINT, SIGTERM, SIGHUP 에서도 지운다
|
|
106
|
+
- 브라우저 칸: 브라우저 종류마다 한 번 띄우고 기기마다 새 컨텍스트. 응답 상태, 페이지 오류 수, 콘솔 오류 수, 제목, 스크린숏(`<out>/browser-<기기>.png`). 통과는 응답 2xx 나 3xx(파일 URL 은 응답 없음)이고 페이지 오류 0
|
|
107
|
+
|
|
108
|
+
## 칸 상태와 사유
|
|
109
|
+
|
|
110
|
+
| 상태 | 사유 | 뜻과 안내 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `pass` | | 통과 |
|
|
113
|
+
| `fail` | `exit-<n>` | 컨테이너나 VM 안 명령이 0 이 아닌 값으로 끝남 |
|
|
114
|
+
| `fail` | `http-<n>` | 응답이 2xx, 3xx 가 아님 |
|
|
115
|
+
| `fail` | `page-error` | 페이지가 예외를 던짐, 또는 페이지를 열지 못함 |
|
|
116
|
+
| `unverified` | `needs-playwright` | Playwright 패키지 없음. setup-playwright |
|
|
117
|
+
| `unverified` | `needs-browser` | 브라우저 빌드 없음. `setup --browsers <이름>` |
|
|
118
|
+
| `unverified` | `needs-image` | 로컬 이미지 없음. `setup --images <os>` 또는 `setup --playwright-image` |
|
|
119
|
+
| `unverified` | `needs-engine` | docker, podman 없음이나 엔진에 연결 못 함 |
|
|
120
|
+
| `unverified` | `needs-network` | `setup` 의 원천에 닿지 못함 |
|
|
121
|
+
| `unverified` | `needs-tool` | 컨테이너 안 명령 종료 127(명령 없음). 이미지에 도구를 넣거나 `--cmd` 를 바꿈 |
|
|
122
|
+
| `unverified` | `needs-tart` | Apple Silicon Mac 에 Tart 없음 |
|
|
123
|
+
| `unverified` | `needs-vm` | Tart VM 없음이나 꺼짐(VM 이미지 약 25GB) |
|
|
124
|
+
| `unverified` | `tart-exec-unsupported` | `tart exec --help` 실패. Tart 를 올림 |
|
|
125
|
+
| `unverified` | `host-deps` | 브라우저 실행에 필요한 OS 라이브러리 없음(Rocky 8 의 WebKit 등). `--browser-in-container` |
|
|
126
|
+
| `unverified` | `launch-failed` | 그 밖의 실행 실패. 로그 확인 |
|
|
127
|
+
| `unverified` | `unknown-device` | 이 Playwright 판에 없는 기기 이름 |
|
|
128
|
+
| `unverified` | `timeout` | 시간 초과 |
|
|
129
|
+
| `unverified` | `sandbox` | 엔진이나 브라우저의 권한 오류(Codex 샌드박스, 소켓 권한). 승인 뒤 다시 |
|
|
130
|
+
| `unsupported` | `apple-sla` | 비 Apple 호스트의 macOS. 요구 축소 항목 |
|
|
131
|
+
| `unsupported` | `tart-needs-apple-silicon` | Intel Mac 이나 x86_64 macOS 가상 머신. Tart 는 Apple Silicon 전용 |
|
|
132
|
+
|
|
133
|
+
- `--url` 이 없으면 브라우저 칸은 만들지 않고 요약에 "브라우저 묶음 요청 안 됨" 한 줄을 쓴다(판정에 넣지 않음). `--os none` 도 같은 꼴
|
|
134
|
+
- Firefox 기기(`Desktop Firefox`)는 사용 오류다(범위 밖)
|
|
135
|
+
|
|
136
|
+
## 종료 코드
|
|
137
|
+
|
|
138
|
+
| 명령 | 코드 |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `run` | 0 요청한 모든 칸 `pass`, 1 `fail` 1칸 이상, 4 `fail` 없이 `unverified` 나 `unsupported` 1칸 이상, 2 사용 오류, 3 내부 오류나 결과 폴더 쓰기 실패, 129, 130, 143 SIGHUP, SIGINT, SIGTERM(만든 컨테이너를 지운 뒤) |
|
|
141
|
+
| `setup` | 0 받고 확인됨(또는 계획만 보임), 1 일부 실패, 4 실패 없이 닿지 못한 원천이 있음("네트워크 필요"), 2 사용 오류 |
|
|
142
|
+
| `detect` | 0, 사용 오류 2 |
|
|
143
|
+
|
|
144
|
+
- 요약 첫 줄: `pass <n>, fail <n>, unverified <n>, unsupported <n>`
|
|
145
|
+
- 결과: `<out>/results.json`(칸마다 상태, 사유, 시작과 끝 시각, 로그 경로, 컨테이너 칸은 네트워크 모드), `<out>/summary.md`. 화면에도 같은 표를 낸다
|
|
146
|
+
|
|
147
|
+
## setup 확인 절차
|
|
148
|
+
|
|
149
|
+
1. `node "<S>/scripts/testbox.mjs" setup [이름들]` 로 계획을 본다(아무것도 받지 않음)
|
|
150
|
+
2. 계획(무엇을, 대략 크기)을 사용자에게 보이고 확인을 받는다. Claude Code `AskUserQuestion`, Codex `request_user_input`
|
|
151
|
+
3. 확인을 받으면 같은 인자에 `--yes` 를 붙여 실행한다. Codex 는 네트워크 승인을 요청한다
|
|
152
|
+
4. 출력의 `needs-network` 줄과 실패 줄을 그대로 보고한다
|
|
153
|
+
|
|
154
|
+
| 대상 | 대략 크기 |
|
|
155
|
+
|---|---|
|
|
156
|
+
| rocky8, rocky9 이미지 | 약 200MB, 약 180MB |
|
|
157
|
+
| chromium(headless shell 포함) | 약 280MB |
|
|
158
|
+
| webkit | 약 100MB |
|
|
159
|
+
| Playwright 공식 이미지(`--playwright-image`) | 약 2GB |
|
|
160
|
+
|
|
161
|
+
- `--yes` 는 받기 전에 원천마다 15초 안의 확인을 한다. 레지스트리는 `https://<호스트>/v2/`(docker.io 는 `registry-1.docker.io`, loopback 레지스트리는 http), 브라우저는 `PLAYWRIGHT_DOWNLOAD_HOST`(chromium 은 `PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST` 도) 또는 `https://cdn.playwright.dev`. 주소의 사용자 정보는 뗀다. HTTP 답이면 401, 404 도 닿은 것이다
|
|
162
|
+
- 닿지 못한 원천의 항목은 받지 않고 `needs-network` 로 남긴다. `CODEX_SANDBOX_NETWORK_DISABLED=1` 이면 "Codex 샌드박스가 네트워크를 막음" 으로 구분한다
|
|
163
|
+
- 받는 순서: 이미지(준 순서), 그다음 브라우저. 하나씩 받고, 받을 때마다 `image inspect` 나 실제 headless 실행으로 확인한다
|
|
164
|
+
- 브라우저 설치는 `npx --no-install playwright install <이름>` 을 홈 폴더에서 실행한다(`--playwright-dir` 이나 `BUILD_TESTBOX_PLAYWRIGHT_DIR` 이면 그 폴더, `--playwright-from-project` 면 작업 폴더)
|
|
165
|
+
- Playwright 패키지를 찾지 못하면 브라우저 항목은 받지 않고 `failed` 로 남기며(종료 1) 찾아본 곳을 적는다
|
|
166
|
+
|
|
167
|
+
## macOS
|
|
168
|
+
|
|
169
|
+
- 비 Apple 호스트: `unsupported(apple-sla)`. Apple macOS 사용권 계약(SLA)은 이미 macOS 가 도는 Apple 하드웨어에서만 가상 사본(최대 2개)을 허용한다. 비 Apple 호스트용 macOS 컨테이너(dockur/macos 등)는 그 프로젝트 스스로 EULA 위반을 밝히므로 안내하지 않는다
|
|
170
|
+
- macOS 지만 Apple Silicon 이 아님: `unsupported(tart-needs-apple-silicon)`. `sysctl hw.model` 은 믿지 않는다. QEMU 위 OpenCore 가상 머신(x86_64)도 `MacPro7,1` 같은 값을 낸다(실측). Apple Silicon 판정은 `process.arch` 가 `arm64` 이거나 `sysctl -n hw.optional.arm64` 가 1(Rosetta 아래 Node)
|
|
171
|
+
- Apple Silicon Mac: Tart 없음 `needs-tart`, VM 없음이나 꺼짐 `needs-vm`, `tart exec --help` 실패 `tart-exec-unsupported`, 그 밖은 `tart exec <vm> -- sh -c "<cmd>"`
|
|
172
|
+
- VM 은 사용자가 `tart clone` 으로 받고(약 25GB) `tart run` 으로 켠다. 이 스킬은 VM 을 받거나 켜지 않는다
|
|
173
|
+
- macOS 칸에는 소스 사본을 넣지 않는다. 명령만 돈다
|
|
174
|
+
- 사용자 보고(요구 축소): macOS 는 Apple Silicon 호스트의 Tart VM 만 지원하고, 비 Apple 호스트는 라이선스 사유로 `unsupported`. 실제 Safari, iOS 시뮬레이터는 WebKit 에뮬레이션으로 대신한다
|
|
175
|
+
|
|
176
|
+
## Codex 샌드박스 승인
|
|
177
|
+
|
|
178
|
+
| 동작 | 이유 | 요청 |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| 컨테이너 실행(`detect`, `run`) | 엔진 소켓, rootless podman 의 사용자 네임스페이스 | `sandbox_permissions: "require_escalated"` |
|
|
181
|
+
| 브라우저 실행(`detect`, `run`) | 브라우저 프로세스와 네임스페이스 | 같음 |
|
|
182
|
+
| 포트 | 시험 대상 서버는 사용자가 띄운 127.0.0.1 포트. 이 스킬은 포트를 열지 않음 | 서버 실행 쪽 승인 |
|
|
183
|
+
| 네트워크(`setup --yes`, `--network bridge` 의 명령) | 레지스트리, 브라우저 서버, 컨테이너 안 `dnf` | 같음 |
|
|
184
|
+
| 작업 폴더 밖 쓰기 | 소스 사본과 실행기 사본을 OS 임시 폴더에 만듦 | 같음 |
|
|
185
|
+
| `--network host` | 그 칸의 네트워크 격리가 풀림. `--browser-in-container` 는 Linux 에서 호스트 loopback 에 닿으려고 이것을 씀 | 같음, 사용자에게 알림 |
|
|
186
|
+
|
|
187
|
+
- Codex 는 명령 하나씩 승인을 요청한다. `&&` 로 묶지 않는다
|
|
188
|
+
- 권한 오류로 끝난 칸은 `unverified(sandbox)` 다. 승인 뒤 다시 실행한다
|
|
189
|
+
|
|
190
|
+
## 오프라인 동작
|
|
191
|
+
|
|
192
|
+
- `run`, `detect` 는 내려받지 않는다. 없는 이미지, 브라우저, 엔진은 `unverified` 와 "무엇을 setup 하면 되는지" 로 끝난다
|
|
193
|
+
- `setup --yes` 는 원천마다 15초 안에 확인하고, 닿지 못하면 받지 않고 종료 4 와 "네트워크 필요" 로 끝난다
|
|
194
|
+
|
|
195
|
+
## 함정
|
|
196
|
+
|
|
197
|
+
- Rocky 8, RHEL 8 은 Playwright 공식 지원 OS 가 아니다. WebKit 은 OS 라이브러리 부족으로 `host-deps` 가 되기 쉽다. `--browser-in-container`(공식 이미지 약 2GB, `setup --playwright-image`)로 돌린다
|
|
198
|
+
- `--browser-in-container` 는 호스트 Playwright 패키지와 그것이 의존하는 `playwright-core` 를 `cp` 로 넣는다. 공식 이미지 판은 호스트 Playwright 판과 같아야 한다. Linux 밖에서는 loopback URL 을 `host.containers.internal`(podman)이나 `host.docker.internal`(docker)로 바꿔 연다. 이 경로는 Linux 밖에서 실측하지 않았다
|
|
199
|
+
- `--network host` 는 칸의 네트워크 격리를 푼다. `results.json` 의 칸에 네트워크 모드가 남는다
|
|
200
|
+
- rootless podman 의 엔진 기본 네트워크는 `bridge` 가 아니라 `slirp4netns` 나 `pasta` 일 수 있다. `results.json` 에는 엔진이 실제로 쓴 모드를 적는다
|
|
201
|
+
- docker 명령이 podman 에뮬레이션이면 엔진 이름은 docker 여도 지우기는 `rm -f -t 0`(즉시 종료)를 쓴다
|
|
202
|
+
- Node 의 fetch 는 `HTTPS_PROXY` 를 따르지 않는다(Node 24 는 `NODE_USE_ENV_PROXY=1` 일 때만). 프록시만 열린 망에서 `setup` 의 확인이 실패하면 `needs-network` 로 보인다
|
|
203
|
+
- `run` 의 기본 결과 폴더 `./.testbox/` 는 작업 폴더 안에 생긴다. git 저장소면 `.gitignore` 에 넣는다
|
|
204
|
+
|
|
205
|
+
## 출처
|
|
206
|
+
|
|
207
|
+
- Playwright 브라우저 위치와 `PLAYWRIGHT_BROWSERS_PATH`: https://playwright.dev/docs/browsers
|
|
208
|
+
- Playwright 기기 에뮬레이션과 `devices`: https://playwright.dev/docs/emulation
|
|
209
|
+
- Playwright 지원 OS: https://playwright.dev/docs/intro , Rocky 8 의 WebKit 라이브러리 부족 보고: https://github.com/microsoft/playwright/issues/11122
|
|
210
|
+
- Playwright 공식 이미지: https://playwright.dev/docs/docker
|
|
211
|
+
- Rocky Linux 이미지(`docker.io/rockylinux/rockylinux`): https://hub.docker.com/r/rockylinux/rockylinux
|
|
212
|
+
- Apple macOS 사용권 계약: https://www.apple.com/legal/sla/ , 비 Apple 호스트 macOS 컨테이너의 EULA 위반 명시: https://github.com/dockur/macos
|
|
213
|
+
- Tart 요구 조건(Apple Silicon, macOS 13 이상)과 라이선스: https://tart.run/quick-start/ , https://tart.run/licensing/
|
|
@@ -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
|
+
}
|