@uzulla/voreux 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 uzulla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Voreux
2
+
3
+ Voreux は、**Stagehand + Vitest** ベースの E2E テストフレームワークです。
4
+
5
+ AI に「クリックして」「入力して」と指示するだけで、Web ブラウザ操作を自動化するテストを書けます。
6
+
7
+ ## 特徴
8
+
9
+ - **AI ファースト**: LLM に自然な指示語でブラウザ操作をさせる
10
+ - **自己修復**: 要素が見つからなくなっても自動でリトライ
11
+ - **スクリーンショット比較**: ビジュアルリグレッションも検出
12
+ - **録画対応**: テスト実行の様子を動画出力
13
+
14
+ ## インストール
15
+
16
+ ```bash
17
+ npm install @uzulla/voreux
18
+ # または
19
+ pnpm add @uzulla/voreux
20
+ ```
21
+
22
+ **必須環境**
23
+
24
+ - Node.js >= 22.x
25
+ - pnpm >= 10.x(推奨)または npm
26
+ - Windows ユーザーは **WSL2** が必要です
27
+
28
+ ## クイックスタート
29
+
30
+ ```bash
31
+ # プロジェクトの雛形を生成
32
+ npx @uzulla/voreux init my-e2e
33
+ cd my-e2e
34
+
35
+ # 依存をインストール
36
+ pnpm install
37
+
38
+ # OPENAI_API_KEY を設定
39
+ cp .env.example .env
40
+ # .env の OPENAI_API_KEY を実際のキーに置き換える
41
+
42
+ # テストを実行
43
+ pnpm test
44
+ ```
45
+
46
+ ## 最初のテストを書く
47
+
48
+ ```ts
49
+ import { defineScenarioSuite } from "@uzulla/voreux";
50
+
51
+ const steps = [
52
+ {
53
+ name: "load page",
54
+ selfHeal: false,
55
+ run: async (ctx) => {
56
+ await ctx.page.goto("https://example.com/");
57
+ },
58
+ },
59
+ ];
60
+
61
+ defineScenarioSuite({
62
+ suiteName: "example",
63
+ originUrl: "https://example.com/",
64
+ steps,
65
+ });
66
+ ```
67
+
68
+ ## 設定
69
+
70
+ ### 環境変数
71
+
72
+ | 変数 | 既定値 | 説明 |
73
+ |---|---|---|
74
+ | `OPENAI_API_KEY` | **必須** | OpenAI API キー |
75
+ | `SELF_HEAL` | `0` | `1` で自己修復を有効化 |
76
+ | `STAGEHAND_MODEL` | `openai/gpt-4o` | 使用するモデル |
77
+ | `E2E_HEADLESS` | `false` | `1` でヘッドレス実行 |
78
+
79
+ ## ライセンス
80
+
81
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { parseArgs } from "util";
7
+ const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version;
8
+ const HELP_TEXT = `Voreux — Stagehand + Vitest E2E testing framework
9
+
10
+ Usage:
11
+ voreux init [dir] [--force] Scaffold a project (default: skips existing files)
12
+ voreux test [pattern] Run vitest tests (default: all)
13
+ voreux --help Show this help
14
+ voreux --version Show version
15
+
16
+ Options:
17
+ --force, -f Overwrite existing files during init
18
+
19
+ Examples:
20
+ voreux init my-e2e
21
+ voreux init my-e2e --force (overwrites any existing files)
22
+ cd my-e2e
23
+ # add OPENAI_API_KEY to .env
24
+ voreux test
25
+
26
+ For more details, see: https://github.com/uzulla/voreux`;
27
+ const INIT_TEMPLATE_FILES = {
28
+ "package.json": JSON.stringify({
29
+ name: "my-voreux-project",
30
+ version: "0.1.0",
31
+ type: "module",
32
+ private: true,
33
+ scripts: {
34
+ test: "vitest run",
35
+ "test:self-heal": "cross-env SELF_HEAL=1 vitest run",
36
+ build: "tsc --noEmit",
37
+ },
38
+ dependencies: {
39
+ "@uzulla/voreux": `^${PKG_VERSION}`,
40
+ zod: "^3.25.76",
41
+ },
42
+ devDependencies: {
43
+ "@types/node": "^25.2.1",
44
+ "cross-env": "^10.1.0",
45
+ typescript: "^5.9.3",
46
+ vitest: "^4.0.18",
47
+ },
48
+ }, null, 2),
49
+ "vitest.config.ts": `import { defineConfig } from "vitest/config";
50
+
51
+ export default defineConfig({
52
+ test: {
53
+ pool: "forks",
54
+ poolOptions: {
55
+ forks: {
56
+ singleFork: true,
57
+ },
58
+ },
59
+ },
60
+ });
61
+ `,
62
+ ".env.example": `OPENAI_API_KEY=sk-...`,
63
+ ".gitignore": `node_modules
64
+ .env
65
+ screenshots
66
+ recordings
67
+ baselines
68
+ *.png
69
+ *.mp4
70
+ `,
71
+ "tsconfig.json": JSON.stringify({
72
+ compilerOptions: {
73
+ target: "ES2022",
74
+ module: "NodeNext",
75
+ moduleResolution: "NodeNext",
76
+ strict: true,
77
+ esModuleInterop: true,
78
+ skipLibCheck: true,
79
+ },
80
+ }, null, 2),
81
+ "tests/example.test.ts": `import { defineScenarioSuite } from "@uzulla/voreux";
82
+
83
+ const steps = [
84
+ {
85
+ name: "load page",
86
+ selfHeal: false,
87
+ run: async (ctx) => {
88
+ await ctx.page.goto("https://example.com/");
89
+ },
90
+ },
91
+ ];
92
+
93
+ defineScenarioSuite({
94
+ suiteName: "example",
95
+ originUrl: "https://example.com/",
96
+ steps,
97
+ });
98
+ `,
99
+ };
100
+ async function cmdInit(targetDir, force) {
101
+ const resolved = targetDir ? path.resolve(targetDir) : process.cwd();
102
+ if (fs.existsSync(resolved) && !fs.statSync(resolved).isDirectory()) {
103
+ console.error(`voreux init: ${resolved} is a file, not a directory.`);
104
+ process.exit(1);
105
+ }
106
+ if (!fs.existsSync(resolved)) {
107
+ fs.mkdirSync(resolved, { recursive: true });
108
+ console.log(`Created directory: ${resolved}`);
109
+ }
110
+ for (const [relativePath, content] of Object.entries(INIT_TEMPLATE_FILES)) {
111
+ const filePath = path.join(resolved, relativePath);
112
+ const dir = path.dirname(filePath);
113
+ if (!fs.existsSync(dir)) {
114
+ fs.mkdirSync(dir, { recursive: true });
115
+ }
116
+ if (fs.existsSync(filePath)) {
117
+ if (force) {
118
+ fs.writeFileSync(filePath, content, "utf-8");
119
+ console.log(`Overwritten: ${relativePath}`);
120
+ }
121
+ else {
122
+ console.log(`Skipped (exists): ${relativePath}`);
123
+ }
124
+ }
125
+ else {
126
+ fs.writeFileSync(filePath, content, "utf-8");
127
+ console.log(`Created: ${relativePath}`);
128
+ }
129
+ }
130
+ console.log(`\nScaffold complete! Next steps:\n` +
131
+ ` cd ${resolved}\n` +
132
+ ` pnpm install\n` +
133
+ ` cp .env.example .env\n` +
134
+ ` # add OPENAI_API_KEY to .env\n` +
135
+ ` pnpm test\n`);
136
+ }
137
+ async function cmdTest(pattern) {
138
+ const args = ["vitest", "run"];
139
+ if (pattern) {
140
+ args.push(pattern);
141
+ }
142
+ console.log(`Running vitest...`);
143
+ const result = spawnSync("pnpm", args, {
144
+ cwd: process.cwd(),
145
+ stdio: "inherit",
146
+ shell: false,
147
+ });
148
+ if (result.status !== 0) {
149
+ process.exit(result.status ?? 1);
150
+ }
151
+ }
152
+ async function main() {
153
+ const { values, positionals } = parseArgs({
154
+ options: {
155
+ force: { type: "boolean", short: "f" },
156
+ help: { type: "boolean", short: "h" },
157
+ version: { type: "boolean", short: "v" },
158
+ },
159
+ allowPositionals: true,
160
+ });
161
+ if (values.help) {
162
+ console.log(HELP_TEXT);
163
+ return;
164
+ }
165
+ if (values.version) {
166
+ console.log(`voreux ${PKG_VERSION}`);
167
+ return;
168
+ }
169
+ const [command, ...rest] = positionals;
170
+ switch (command) {
171
+ case "init":
172
+ await cmdInit(rest[0], values.force ?? false);
173
+ break;
174
+ case "test":
175
+ await cmdTest(rest[0]);
176
+ break;
177
+ case undefined:
178
+ console.log(HELP_TEXT);
179
+ break;
180
+ default:
181
+ console.error(`Unknown command: ${command}`);
182
+ console.log(`Run 'voreux --help' for usage.`);
183
+ process.exit(1);
184
+ }
185
+ }
186
+ main().catch((err) => {
187
+ console.error("voreux: fatal error:", err);
188
+ process.exit(1);
189
+ });
@@ -0,0 +1,35 @@
1
+ export interface E2EFrameworkConfig {
2
+ stagehand: {
3
+ model: string;
4
+ cacheDir: string;
5
+ };
6
+ paths: {
7
+ screenshotsDir: string;
8
+ recordingsDir: string;
9
+ baselinesDir: string;
10
+ };
11
+ browser: {
12
+ headless: boolean;
13
+ viewport: {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ };
18
+ videoRecording: {
19
+ frameIntervalMs: number;
20
+ injectFrameCount: number;
21
+ };
22
+ navigation: {
23
+ timeoutMs: number;
24
+ pollIntervalMs: number;
25
+ };
26
+ visualRegression: {
27
+ mismatchThreshold: number;
28
+ };
29
+ }
30
+ /**
31
+ * フレームワーク共通設定。
32
+ * 環境変数でオーバーライド可能にして、各プロジェクトで再利用しやすくする。
33
+ */
34
+ export declare const frameworkConfig: E2EFrameworkConfig;
35
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE;QACT,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,KAAK,EAAE;QACL,cAAc,EAAE,MAAM,CAAC;QACvB,aAAa,EAAE,MAAM,CAAC;QACtB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,OAAO,EAAE;QACP,QAAQ,EAAE,OAAO,CAAC;QAClB,QAAQ,EAAE;YACR,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;SAChB,CAAC;KACH,CAAC;IACF,cAAc,EAAE;QACd,eAAe,EAAE,MAAM,CAAC;QACxB,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;IACF,UAAU,EAAE;QACV,SAAS,EAAE,MAAM,CAAC;QAClB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;IACF,gBAAgB,EAAE;QAChB,iBAAiB,EAAE,MAAM,CAAC;KAC3B,CAAC;CACH;AAwBD;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,kBAmC7B,CAAC"}
package/dist/config.js ADDED
@@ -0,0 +1,55 @@
1
+ const toInt = (value, fallback) => {
2
+ if (!value)
3
+ return fallback;
4
+ const parsed = Number.parseInt(value, 10);
5
+ return Number.isFinite(parsed) ? parsed : fallback;
6
+ };
7
+ const toFloat = (value, fallback) => {
8
+ if (!value)
9
+ return fallback;
10
+ const parsed = Number.parseFloat(value);
11
+ return Number.isFinite(parsed) ? parsed : fallback;
12
+ };
13
+ const toBool = (value, fallback) => {
14
+ if (!value)
15
+ return fallback;
16
+ if (value === "1" || value.toLowerCase() === "true")
17
+ return true;
18
+ if (value === "0" || value.toLowerCase() === "false")
19
+ return false;
20
+ return fallback;
21
+ };
22
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
23
+ /**
24
+ * フレームワーク共通設定。
25
+ * 環境変数でオーバーライド可能にして、各プロジェクトで再利用しやすくする。
26
+ */
27
+ export const frameworkConfig = {
28
+ stagehand: {
29
+ model: process.env.STAGEHAND_MODEL ?? "openai/gpt-4o",
30
+ cacheDir: process.env.STAGEHAND_CACHE_DIR ?? ".cache/stagehand-e2e",
31
+ },
32
+ paths: {
33
+ screenshotsDir: process.env.E2E_SCREENSHOTS_DIR ?? "screenshots",
34
+ recordingsDir: process.env.E2E_RECORDINGS_DIR ?? "recordings",
35
+ baselinesDir: process.env.E2E_BASELINES_DIR ?? "baselines",
36
+ },
37
+ browser: {
38
+ headless: toBool(process.env.E2E_HEADLESS, false),
39
+ viewport: {
40
+ width: toInt(process.env.E2E_VIEWPORT_WIDTH, 1280),
41
+ height: toInt(process.env.E2E_VIEWPORT_HEIGHT, 720),
42
+ },
43
+ },
44
+ videoRecording: {
45
+ frameIntervalMs: Math.max(1, toInt(process.env.E2E_FRAME_INTERVAL_MS, 500)),
46
+ injectFrameCount: Math.max(1, toInt(process.env.E2E_INJECT_FRAME_COUNT, 3)),
47
+ },
48
+ navigation: {
49
+ timeoutMs: Math.max(1, toInt(process.env.E2E_NAV_TIMEOUT_MS, 10000)),
50
+ pollIntervalMs: Math.max(1, toInt(process.env.E2E_NAV_POLL_INTERVAL_MS, 300)),
51
+ },
52
+ visualRegression: {
53
+ mismatchThreshold: clamp(toFloat(process.env.E2E_VISUAL_DIFF_THRESHOLD, 0.1), 0, 1),
54
+ },
55
+ };
@@ -0,0 +1,32 @@
1
+ import type { Stagehand } from "@browserbasehq/stagehand";
2
+ import type { Recorder } from "./recording.js";
3
+ import type { ScreenshotFn } from "./screenshot.js";
4
+ export declare const SCREENSHOT_DIR: string;
5
+ export declare const RECORDING_DIR: string;
6
+ export declare const FRAMES_DIR: string;
7
+ export declare const BASELINES_DIR: string;
8
+ export declare const VISUAL_DIFF_THRESHOLD: number;
9
+ export declare class VisualRegressionError extends Error {
10
+ diffPath: string;
11
+ mismatchRatio: number;
12
+ constructor(diffPath: string, mismatchRatio: number);
13
+ }
14
+ export interface TestContext {
15
+ stagehand: Stagehand;
16
+ page: any;
17
+ screenshot: ScreenshotFn;
18
+ recorder: Recorder;
19
+ originUrl: string;
20
+ /** act() 実行 → 新タブ or 同一タブ遷移を待って結果ページを返す */
21
+ actAndWaitForNav: (instruction: string, urlPattern: string) => Promise<any>;
22
+ /** ビューポートのスクリーンショットをベースラインと比較し、差異超過なら throw */
23
+ assertNoVisualRegression: (baselineName: string) => Promise<void>;
24
+ /** 現在のスクリーンショットをベースラインとして保存 */
25
+ saveCurrentBaseline: (baselineName: string) => void;
26
+ /** observe() 結果を一括ハイライト → screenshot → 録画フレーム注入 → ハイライト除去 */
27
+ highlightObserved: (actions: any[], screenshotName: string) => Promise<void>;
28
+ /** observe() で要素を探し、単一ハイライト → screenshot → 録画フレーム注入 → ハイライト除去 */
29
+ highlightTarget: (instruction: string, screenshotName: string) => Promise<void>;
30
+ }
31
+ export declare function createTestContext(stagehand: Stagehand, page: any, recorder: Recorder, screenshotFn: ScreenshotFn, originUrl: string): TestContext;
32
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAQ1D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAOpD,eAAO,MAAM,cAAc,QAE1B,CAAC;AACF,eAAO,MAAM,aAAa,QAAoD,CAAC;AAC/E,eAAO,MAAM,UAAU,QAAqC,CAAC;AAC7D,eAAO,MAAM,aAAa,QAAmD,CAAC;AAE9E,eAAO,MAAM,qBAAqB,QACkB,CAAC;AAMrD,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;gBACV,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM;CAQpD;AAMD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,SAAS,CAAC;IACrB,IAAI,EAAE,GAAG,CAAC;IACV,UAAU,EAAE,YAAY,CAAC;IACzB,QAAQ,EAAE,QAAQ,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAElB,2CAA2C;IAC3C,gBAAgB,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAE5E,+CAA+C;IAC/C,wBAAwB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAElE,+BAA+B;IAC/B,mBAAmB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IAEpD,6DAA6D;IAC7D,iBAAiB,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,cAAc,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7E,iEAAiE;IACjE,eAAe,EAAE,CACf,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,KACnB,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,SAAS,EACpB,IAAI,EAAE,GAAG,EACT,QAAQ,EAAE,QAAQ,EAClB,YAAY,EAAE,YAAY,EAC1B,SAAS,EAAE,MAAM,GAChB,WAAW,CA4Gb"}
@@ -0,0 +1,112 @@
1
+ import path from "path";
2
+ import { frameworkConfig } from "./config.js";
3
+ import { highlightElement, highlightElements, removeHighlights, } from "./highlight.js";
4
+ import { compareWithBaseline, saveBaseline } from "./screenshot.js";
5
+ // ----------------------------------------------------------------
6
+ // 定数
7
+ // ----------------------------------------------------------------
8
+ export const SCREENSHOT_DIR = path.resolve(frameworkConfig.paths.screenshotsDir);
9
+ export const RECORDING_DIR = path.resolve(frameworkConfig.paths.recordingsDir);
10
+ export const FRAMES_DIR = path.join(RECORDING_DIR, "frames");
11
+ export const BASELINES_DIR = path.resolve(frameworkConfig.paths.baselinesDir);
12
+ export const VISUAL_DIFF_THRESHOLD = frameworkConfig.visualRegression.mismatchThreshold;
13
+ // ----------------------------------------------------------------
14
+ // VisualRegressionError
15
+ // ----------------------------------------------------------------
16
+ export class VisualRegressionError extends Error {
17
+ diffPath;
18
+ mismatchRatio;
19
+ constructor(diffPath, mismatchRatio) {
20
+ super(`Visual regression detected (${(mismatchRatio * 100).toFixed(1)}% mismatch). Diff: ${diffPath}`);
21
+ this.name = "VisualRegressionError";
22
+ this.diffPath = diffPath;
23
+ this.mismatchRatio = mismatchRatio;
24
+ }
25
+ }
26
+ export function createTestContext(stagehand, page, recorder, screenshotFn, originUrl) {
27
+ let lastComparisonSsPath = null;
28
+ const ctx = {
29
+ stagehand,
30
+ page,
31
+ recorder,
32
+ originUrl,
33
+ screenshot: screenshotFn,
34
+ async actAndWaitForNav(instruction, urlPattern) {
35
+ const initialPages = new Set(stagehand.context.pages());
36
+ await stagehand.act(instruction);
37
+ // 新タブ or 同一タブ遷移で URL が urlPattern にマッチするまでポーリング
38
+ const deadline = Date.now() + frameworkConfig.navigation.timeoutMs;
39
+ let resultPage = null;
40
+ while (Date.now() < deadline) {
41
+ // 新しく開いたページのみチェック
42
+ const newPages = stagehand.context
43
+ .pages()
44
+ .filter((p) => !initialPages.has(p));
45
+ const matchedPage = newPages.find((p) => p.url().includes(urlPattern));
46
+ if (matchedPage) {
47
+ resultPage = matchedPage;
48
+ break;
49
+ }
50
+ // 同一タブ遷移チェック
51
+ if (page.url().includes(urlPattern)) {
52
+ resultPage = page;
53
+ break;
54
+ }
55
+ await new Promise((r) => setTimeout(r, frameworkConfig.navigation.pollIntervalMs));
56
+ }
57
+ if (!resultPage) {
58
+ throw new Error(`Navigation failed: no page matching "${urlPattern}" found`);
59
+ }
60
+ if (resultPage !== page) {
61
+ await resultPage.waitForLoadState("domcontentloaded").catch(() => { });
62
+ }
63
+ return resultPage;
64
+ },
65
+ async assertNoVisualRegression(baselineName) {
66
+ const ssPath = path.join(SCREENSHOT_DIR, `${baselineName}.png`);
67
+ await page.screenshot({ path: ssPath });
68
+ lastComparisonSsPath = ssPath;
69
+ const diffPath = path.join(SCREENSHOT_DIR, `${baselineName}-diff.png`);
70
+ const comparison = compareWithBaseline(ssPath, baselineName, {
71
+ baselinesDir: BASELINES_DIR,
72
+ diffPath,
73
+ });
74
+ if (!comparison.skipped &&
75
+ comparison.mismatchRatio > VISUAL_DIFF_THRESHOLD) {
76
+ throw new VisualRegressionError(diffPath, comparison.mismatchRatio);
77
+ }
78
+ },
79
+ saveCurrentBaseline(baselineName) {
80
+ if (lastComparisonSsPath) {
81
+ saveBaseline(lastComparisonSsPath, baselineName, BASELINES_DIR);
82
+ }
83
+ },
84
+ async highlightObserved(actions, screenshotName) {
85
+ await highlightElements(page, actions);
86
+ try {
87
+ await screenshotFn(screenshotName);
88
+ await recorder.injectFrames(frameworkConfig.videoRecording.injectFrameCount);
89
+ }
90
+ finally {
91
+ await removeHighlights(page);
92
+ }
93
+ },
94
+ async highlightTarget(instruction, screenshotName) {
95
+ const targets = await stagehand.observe(instruction);
96
+ if (targets.length > 0) {
97
+ await highlightElement(page, targets[0].selector, {
98
+ showCursor: true,
99
+ label: "Click target",
100
+ });
101
+ try {
102
+ await screenshotFn(screenshotName);
103
+ await recorder.injectFrames(frameworkConfig.videoRecording.injectFrameCount);
104
+ }
105
+ finally {
106
+ await removeHighlights(page);
107
+ }
108
+ }
109
+ },
110
+ };
111
+ return ctx;
112
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * DOM ベースのハイライト・カーソル可視化ヘルパー。
3
+ * page.evaluate() でオーバーレイを注入し、page.screenshot() に反映させる。
4
+ */
5
+ interface HighlightElementOpts {
6
+ showCursor?: boolean;
7
+ label?: string;
8
+ overlayColor?: string;
9
+ borderColor?: string;
10
+ }
11
+ /**
12
+ * 単一要素をハイライト + オプションでカーソル表示。
13
+ * act() のクリック対象の可視化用。
14
+ */
15
+ export declare function highlightElement(page: any, selector: string, opts?: HighlightElementOpts): Promise<void>;
16
+ interface ObservedAction {
17
+ selector: string;
18
+ description: string;
19
+ [key: string]: any;
20
+ }
21
+ /**
22
+ * 複数要素をナンバリング付きでハイライト。
23
+ * observe() 結果の可視化用。
24
+ */
25
+ export declare function highlightElements(page: any, actions: ObservedAction[]): Promise<void>;
26
+ /**
27
+ * 注入したハイライトオーバーレイを除去する。
28
+ */
29
+ export declare function removeHighlights(page: any): Promise<void>;
30
+ export {};
31
+ //# sourceMappingURL=highlight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"highlight.d.ts","sourceRoot":"","sources":["../src/highlight.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAmBH,UAAU,oBAAoB;IAC5B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,GAAG,EACT,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE,oBAAyB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAmHf;AAED,UAAU,cAAc;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,GAAG,EACT,OAAO,EAAE,cAAc,EAAE,GACxB,OAAO,CAAC,IAAI,CAAC,CA8Gf;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D"}
@@ -0,0 +1,225 @@
1
+ /**
2
+ * DOM ベースのハイライト・カーソル可視化ヘルパー。
3
+ * page.evaluate() でオーバーレイを注入し、page.screenshot() に反映させる。
4
+ */
5
+ const HIGHLIGHT_CONTAINER_ID = "__stagehand_highlight_container__";
6
+ const COLOR_PALETTE = [
7
+ { bg: "rgba(255, 152, 0, 0.25)", border: "rgba(255, 152, 0, 0.8)" },
8
+ { bg: "rgba(33, 150, 243, 0.25)", border: "rgba(33, 150, 243, 0.8)" },
9
+ { bg: "rgba(156, 39, 176, 0.25)", border: "rgba(156, 39, 176, 0.8)" },
10
+ { bg: "rgba(0, 150, 136, 0.25)", border: "rgba(0, 150, 136, 0.8)" },
11
+ { bg: "rgba(244, 67, 54, 0.25)", border: "rgba(244, 67, 54, 0.8)" },
12
+ { bg: "rgba(76, 175, 80, 0.25)", border: "rgba(76, 175, 80, 0.8)" },
13
+ { bg: "rgba(255, 235, 59, 0.30)", border: "rgba(255, 193, 7, 0.8)" },
14
+ { bg: "rgba(121, 85, 72, 0.25)", border: "rgba(121, 85, 72, 0.8)" },
15
+ ];
16
+ const CURSOR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
17
+ <path d="M5 3l14 8-6.5 2L9 19.5z" fill="#222" stroke="#fff" stroke-width="1.5"/>
18
+ </svg>`;
19
+ /**
20
+ * 単一要素をハイライト + オプションでカーソル表示。
21
+ * act() のクリック対象の可視化用。
22
+ */
23
+ export async function highlightElement(page, selector, opts = {}) {
24
+ const { showCursor = false, label = "", overlayColor = "rgba(255, 152, 0, 0.3)", borderColor = "rgba(255, 152, 0, 0.8)", } = opts;
25
+ await page.evaluate(({ selector, overlayColor, borderColor, showCursor, label, cursorSvg, containerId, }) => {
26
+ let el = null;
27
+ try {
28
+ if (selector.startsWith("xpath=")) {
29
+ const xpath = selector.slice("xpath=".length);
30
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
31
+ if (node instanceof Element) {
32
+ el = node;
33
+ }
34
+ }
35
+ else {
36
+ el = document.querySelector(selector);
37
+ }
38
+ }
39
+ catch {
40
+ return;
41
+ }
42
+ if (!el)
43
+ return;
44
+ el.scrollIntoView?.({
45
+ block: "center",
46
+ behavior: "instant",
47
+ });
48
+ const rect = el.getBoundingClientRect();
49
+ const scrollX = window.scrollX;
50
+ const scrollY = window.scrollY;
51
+ // Ensure container
52
+ let container = document.getElementById(containerId);
53
+ if (!container) {
54
+ container = document.createElement("div");
55
+ container.id = containerId;
56
+ container.style.position = "absolute";
57
+ container.style.top = "0";
58
+ container.style.left = "0";
59
+ container.style.width = `${document.documentElement.scrollWidth}px`;
60
+ container.style.height = `${document.documentElement.scrollHeight}px`;
61
+ container.style.pointerEvents = "none";
62
+ container.style.zIndex = "2147483647";
63
+ document.documentElement.appendChild(container);
64
+ }
65
+ // Overlay box
66
+ const overlay = document.createElement("div");
67
+ overlay.style.position = "absolute";
68
+ overlay.style.left = `${rect.left + scrollX - 3}px`;
69
+ overlay.style.top = `${rect.top + scrollY - 3}px`;
70
+ overlay.style.width = `${rect.width + 6}px`;
71
+ overlay.style.height = `${rect.height + 6}px`;
72
+ overlay.style.background = overlayColor;
73
+ overlay.style.border = `3px solid ${borderColor}`;
74
+ overlay.style.borderRadius = "4px";
75
+ overlay.style.boxSizing = "border-box";
76
+ container.appendChild(overlay);
77
+ // Label badge
78
+ if (label) {
79
+ const badge = document.createElement("div");
80
+ badge.textContent = label;
81
+ badge.style.position = "absolute";
82
+ badge.style.left = `${rect.left + scrollX - 3}px`;
83
+ badge.style.top = `${rect.top + scrollY - 22}px`;
84
+ badge.style.background = borderColor;
85
+ badge.style.color = "#fff";
86
+ badge.style.fontSize = "11px";
87
+ badge.style.fontFamily = "Arial, sans-serif";
88
+ badge.style.fontWeight = "bold";
89
+ badge.style.padding = "2px 6px";
90
+ badge.style.borderRadius = "3px 3px 0 0";
91
+ badge.style.whiteSpace = "nowrap";
92
+ container.appendChild(badge);
93
+ }
94
+ // Cursor SVG
95
+ if (showCursor) {
96
+ const cursor = document.createElement("div");
97
+ cursor.innerHTML = cursorSvg;
98
+ cursor.style.position = "absolute";
99
+ cursor.style.left = `${rect.left + scrollX + rect.width / 2}px`;
100
+ cursor.style.top = `${rect.top + scrollY + rect.height / 2}px`;
101
+ container.appendChild(cursor);
102
+ }
103
+ }, {
104
+ selector,
105
+ overlayColor,
106
+ borderColor,
107
+ showCursor,
108
+ label,
109
+ cursorSvg: CURSOR_SVG,
110
+ containerId: HIGHLIGHT_CONTAINER_ID,
111
+ });
112
+ }
113
+ /**
114
+ * 複数要素をナンバリング付きでハイライト。
115
+ * observe() 結果の可視化用。
116
+ */
117
+ export async function highlightElements(page, actions) {
118
+ await page.evaluate(({ actions, palette, containerId }) => {
119
+ // Ensure container
120
+ let container = document.getElementById(containerId);
121
+ if (!container) {
122
+ container = document.createElement("div");
123
+ container.id = containerId;
124
+ container.style.position = "absolute";
125
+ container.style.top = "0";
126
+ container.style.left = "0";
127
+ container.style.width = `${document.documentElement.scrollWidth}px`;
128
+ container.style.height = `${document.documentElement.scrollHeight}px`;
129
+ container.style.pointerEvents = "none";
130
+ container.style.zIndex = "2147483647";
131
+ document.documentElement.appendChild(container);
132
+ }
133
+ for (let i = 0; i < actions.length; i++) {
134
+ const action = actions[i];
135
+ const color = palette[i % palette.length];
136
+ let el = null;
137
+ try {
138
+ if (action.selector.startsWith("xpath=")) {
139
+ const xpath = action.selector.slice("xpath=".length);
140
+ const node = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
141
+ if (node instanceof Element) {
142
+ el = node;
143
+ }
144
+ }
145
+ else {
146
+ el = document.querySelector(action.selector);
147
+ }
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (!el)
153
+ continue;
154
+ const rect = el.getBoundingClientRect();
155
+ const scrollX = window.scrollX;
156
+ const scrollY = window.scrollY;
157
+ // Overlay box
158
+ const overlay = document.createElement("div");
159
+ overlay.style.position = "absolute";
160
+ overlay.style.left = `${rect.left + scrollX - 2}px`;
161
+ overlay.style.top = `${rect.top + scrollY - 2}px`;
162
+ overlay.style.width = `${rect.width + 4}px`;
163
+ overlay.style.height = `${rect.height + 4}px`;
164
+ overlay.style.background = color.bg;
165
+ overlay.style.border = `2px solid ${color.border}`;
166
+ overlay.style.borderRadius = "3px";
167
+ overlay.style.boxSizing = "border-box";
168
+ container.appendChild(overlay);
169
+ // Number badge
170
+ const badge = document.createElement("div");
171
+ badge.textContent = String(i + 1);
172
+ badge.style.position = "absolute";
173
+ badge.style.left = `${rect.left + scrollX - 2}px`;
174
+ badge.style.top = `${rect.top + scrollY - 20}px`;
175
+ badge.style.background = color.border;
176
+ badge.style.color = "#fff";
177
+ badge.style.fontSize = "11px";
178
+ badge.style.fontFamily = "Arial, sans-serif";
179
+ badge.style.fontWeight = "bold";
180
+ badge.style.padding = "1px 5px";
181
+ badge.style.borderRadius = "3px 3px 0 0";
182
+ badge.style.whiteSpace = "nowrap";
183
+ badge.style.maxWidth = "200px";
184
+ badge.style.overflow = "hidden";
185
+ badge.style.textOverflow = "ellipsis";
186
+ container.appendChild(badge);
187
+ // Description tooltip (below the element)
188
+ if (action.description) {
189
+ const tip = document.createElement("div");
190
+ tip.textContent = `${i + 1}. ${action.description}`;
191
+ tip.style.position = "absolute";
192
+ tip.style.left = `${rect.left + scrollX - 2}px`;
193
+ tip.style.top = `${rect.top + scrollY + rect.height + 4}px`;
194
+ tip.style.background = "rgba(0,0,0,0.75)";
195
+ tip.style.color = "#fff";
196
+ tip.style.fontSize = "10px";
197
+ tip.style.fontFamily = "Arial, sans-serif";
198
+ tip.style.padding = "2px 5px";
199
+ tip.style.borderRadius = "2px";
200
+ tip.style.whiteSpace = "nowrap";
201
+ tip.style.maxWidth = "300px";
202
+ tip.style.overflow = "hidden";
203
+ tip.style.textOverflow = "ellipsis";
204
+ container.appendChild(tip);
205
+ }
206
+ }
207
+ }, {
208
+ actions: actions.map((a) => ({
209
+ selector: a.selector,
210
+ description: a.description,
211
+ })),
212
+ palette: COLOR_PALETTE,
213
+ containerId: HIGHLIGHT_CONTAINER_ID,
214
+ });
215
+ }
216
+ /**
217
+ * 注入したハイライトオーバーレイを除去する。
218
+ */
219
+ export async function removeHighlights(page) {
220
+ await page.evaluate((containerId) => {
221
+ const c = document.getElementById(containerId);
222
+ if (c)
223
+ c.remove();
224
+ }, HIGHLIGHT_CONTAINER_ID);
225
+ }
@@ -0,0 +1,3 @@
1
+ export type { ScenarioStep, ScenarioSuiteOptions } from "./scenario.js";
2
+ export { defineScenarioSuite } from "./scenario.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { defineScenarioSuite } from "./scenario.js";
@@ -0,0 +1,23 @@
1
+ export interface Recorder {
2
+ stop: () => Promise<number>;
3
+ /** 現在のページ状態をフレームとして即座に n 枚書き込む */
4
+ injectFrames: (n?: number) => Promise<void>;
5
+ }
6
+ /**
7
+ * ffmpeg コマンドが利用可能かを確認する。
8
+ * 結果はプロセス内でキャッシュされる。
9
+ */
10
+ export declare function hasFfmpegCommand(): boolean;
11
+ /** ffmpeg 未インストール時用の no-op recorder */
12
+ export declare function createNoopRecorder(): Recorder;
13
+ /**
14
+ * ページのフレームキャプチャを開始する。
15
+ * 返り値の stop() を呼ぶとキャプチャを終了し、撮影したフレーム数を返す。
16
+ */
17
+ export declare function startRecording(page: any, framesDir: string, intervalMs?: number): Recorder;
18
+ /**
19
+ * フレーム画像群を ffmpeg で MP4 動画に変換する。
20
+ * 成功時は動画パスを返し、失敗時は null を返す。
21
+ */
22
+ export declare function framesToVideo(framesDir: string, outputDir: string, fps: number): string | null;
23
+ //# sourceMappingURL=recording.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recording.d.ts","sourceRoot":"","sources":["../src/recording.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5B,kCAAkC;IAClC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAID;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAW1C;AAED,uCAAuC;AACvC,wBAAgB,kBAAkB,IAAI,QAAQ,CAK7C;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,GAAG,EACT,SAAS,EAAE,MAAM,EACjB,UAAU,SAAM,GACf,QAAQ,CA8CV;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,MAAM,GAAG,IAAI,CAcf"}
@@ -0,0 +1,89 @@
1
+ import { execSync } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ let ffmpegAvailableCache;
5
+ /**
6
+ * ffmpeg コマンドが利用可能かを確認する。
7
+ * 結果はプロセス内でキャッシュされる。
8
+ */
9
+ export function hasFfmpegCommand() {
10
+ if (ffmpegAvailableCache !== undefined)
11
+ return ffmpegAvailableCache;
12
+ try {
13
+ execSync("ffmpeg -version", { stdio: "ignore" });
14
+ ffmpegAvailableCache = true;
15
+ }
16
+ catch {
17
+ ffmpegAvailableCache = false;
18
+ }
19
+ return ffmpegAvailableCache;
20
+ }
21
+ /** ffmpeg 未インストール時用の no-op recorder */
22
+ export function createNoopRecorder() {
23
+ return {
24
+ stop: async () => 0,
25
+ injectFrames: async () => { },
26
+ };
27
+ }
28
+ /**
29
+ * ページのフレームキャプチャを開始する。
30
+ * 返り値の stop() を呼ぶとキャプチャを終了し、撮影したフレーム数を返す。
31
+ */
32
+ export function startRecording(page, framesDir, intervalMs = 500) {
33
+ let frameIndex = 0;
34
+ let stopped = false;
35
+ let injecting = false;
36
+ const capture = async () => {
37
+ while (!stopped) {
38
+ if (!injecting) {
39
+ try {
40
+ const filePath = path.join(framesDir, `frame-${String(frameIndex).padStart(5, "0")}.png`);
41
+ await page.screenshot({ path: filePath });
42
+ frameIndex++;
43
+ }
44
+ catch {
45
+ // ブラウザが閉じられた等の場合は無視
46
+ }
47
+ }
48
+ await new Promise((r) => setTimeout(r, intervalMs));
49
+ }
50
+ };
51
+ const promise = capture();
52
+ return {
53
+ injectFrames: async (n = 3) => {
54
+ injecting = true;
55
+ try {
56
+ for (let i = 0; i < n; i++) {
57
+ const filePath = path.join(framesDir, `frame-${String(frameIndex).padStart(5, "0")}.png`);
58
+ await page.screenshot({ path: filePath });
59
+ frameIndex++;
60
+ }
61
+ }
62
+ catch { }
63
+ injecting = false;
64
+ },
65
+ stop: async () => {
66
+ stopped = true;
67
+ await promise;
68
+ return frameIndex;
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * フレーム画像群を ffmpeg で MP4 動画に変換する。
74
+ * 成功時は動画パスを返し、失敗時は null を返す。
75
+ */
76
+ export function framesToVideo(framesDir, outputDir, fps) {
77
+ const outputPath = path.join(outputDir, "test-recording.mp4");
78
+ const pattern = path.join(framesDir, "frame-%05d.png");
79
+ try {
80
+ execSync(`ffmpeg -y -framerate ${fps} -i "${pattern}" -c:v libx264 -pix_fmt yuv420p "${outputPath}" 2>/dev/null`);
81
+ // フレーム画像を削除
82
+ fs.rmSync(framesDir, { recursive: true });
83
+ return outputPath;
84
+ }
85
+ catch {
86
+ console.error(" ffmpeg conversion failed. Raw frames kept in:", framesDir);
87
+ return null;
88
+ }
89
+ }
@@ -0,0 +1,18 @@
1
+ import type { TestContext } from "./context.js";
2
+ export interface ScenarioStep {
3
+ name: string;
4
+ run: (ctx: TestContext) => Promise<void>;
5
+ /** false を指定すると self-heal をスキップ(既定は適用) */
6
+ selfHeal?: boolean;
7
+ }
8
+ export interface ScenarioSuiteOptions {
9
+ suiteName: string;
10
+ originUrl: string;
11
+ steps: ScenarioStep[];
12
+ }
13
+ /**
14
+ * E2E シナリオ実行の共通ライフサイクルを提供する。
15
+ * 利用者は steps を並べるだけでシナリオを定義できる。
16
+ */
17
+ export declare function defineScenarioSuite({ suiteName, originUrl, steps, }: ScenarioSuiteOptions): void;
18
+ //# sourceMappingURL=scenario.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scenario.d.ts","sourceRoot":"","sources":["../src/scenario.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAKhD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,SAAS,EACT,SAAS,EACT,KAAK,GACN,EAAE,oBAAoB,GAAG,IAAI,CA8C7B"}
@@ -0,0 +1,49 @@
1
+ import { afterAll, afterEach, beforeAll, describe, test } from "vitest";
2
+ import { withSelfHeal } from "./self-heal.js";
3
+ import { closeStagehand, initStagehand } from "./stagehand.js";
4
+ /**
5
+ * E2E シナリオ実行の共通ライフサイクルを提供する。
6
+ * 利用者は steps を並べるだけでシナリオを定義できる。
7
+ */
8
+ export function defineScenarioSuite({ suiteName, originUrl, steps, }) {
9
+ describe(suiteName, () => {
10
+ let _ctx;
11
+ let _recorder;
12
+ const getCtx = () => {
13
+ if (!_ctx)
14
+ throw new Error("ctx not initialized");
15
+ return _ctx;
16
+ };
17
+ beforeAll(async () => {
18
+ ({ ctx: _ctx, recorder: _recorder } = await initStagehand(originUrl));
19
+ });
20
+ afterAll(async () => {
21
+ if (_ctx && _recorder) {
22
+ await closeStagehand(_ctx, _recorder).catch((e) => console.error("closeStagehand error:", e));
23
+ }
24
+ });
25
+ afterEach(async (testCtx) => {
26
+ if (_ctx && testCtx.task.result?.state === "fail") {
27
+ const name = `error-${testCtx.task.name.replace(/\s+/g, "-")}`;
28
+ try {
29
+ await _ctx.screenshot(name);
30
+ }
31
+ catch (err) {
32
+ console.warn(`Failed to capture error screenshot "${name}":`, err);
33
+ }
34
+ }
35
+ });
36
+ for (const step of steps) {
37
+ test(step.name, async () => {
38
+ const ctx = getCtx();
39
+ if (step.selfHeal === false) {
40
+ await step.run(ctx);
41
+ return;
42
+ }
43
+ await withSelfHeal(ctx, async () => {
44
+ await step.run(ctx);
45
+ });
46
+ });
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,23 @@
1
+ export type ScreenshotFn = (name: string, targetPage?: any) => Promise<string>;
2
+ /**
3
+ * スクリーンショット撮影ヘルパーを生成する。
4
+ * 返り値の関数は名前を受け取ってスクリーンショットを保存し、ファイルパスを返す。
5
+ */
6
+ export declare function createScreenshotHelper(page: any, dir: string): ScreenshotFn;
7
+ /**
8
+ * 2枚の PNG スクリーンショットを比較し、差異の割合と diff 画像を返す。
9
+ * ベースラインが存在しない場合は比較をスキップ。
10
+ */
11
+ export declare function compareWithBaseline(currentPath: string, baselineName: string, opts: {
12
+ baselinesDir: string;
13
+ diffPath: string;
14
+ }): {
15
+ mismatchRatio: number;
16
+ diffSaved: boolean;
17
+ skipped: boolean;
18
+ };
19
+ /**
20
+ * 現在のスクリーンショットをベースラインとして保存する。
21
+ */
22
+ export declare function saveBaseline(screenshotPath: string, baselineName: string, baselinesDir: string): string;
23
+ //# sourceMappingURL=screenshot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../src/screenshot.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE/E;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,GAAG,YAAY,CAU3E;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CA6BjE;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,cAAc,EAAE,MAAM,EACtB,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,GACnB,MAAM,CAOR"}
@@ -0,0 +1,53 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import pixelmatch from "pixelmatch";
4
+ import { PNG } from "pngjs";
5
+ /**
6
+ * スクリーンショット撮影ヘルパーを生成する。
7
+ * 返り値の関数は名前を受け取ってスクリーンショットを保存し、ファイルパスを返す。
8
+ */
9
+ export function createScreenshotHelper(page, dir) {
10
+ return async (name, targetPage = page) => {
11
+ const filePath = path.join(dir, `${name}.png`);
12
+ try {
13
+ await targetPage.screenshot({ path: filePath, fullPage: true });
14
+ }
15
+ catch {
16
+ await targetPage.screenshot({ path: filePath });
17
+ }
18
+ return filePath;
19
+ };
20
+ }
21
+ /**
22
+ * 2枚の PNG スクリーンショットを比較し、差異の割合と diff 画像を返す。
23
+ * ベースラインが存在しない場合は比較をスキップ。
24
+ */
25
+ export function compareWithBaseline(currentPath, baselineName, opts) {
26
+ const baselinePath = path.join(opts.baselinesDir, `${baselineName}.png`);
27
+ if (!fs.existsSync(baselinePath)) {
28
+ return { mismatchRatio: 0, diffSaved: false, skipped: true };
29
+ }
30
+ const img1 = PNG.sync.read(fs.readFileSync(currentPath));
31
+ const img2 = PNG.sync.read(fs.readFileSync(baselinePath));
32
+ // サイズが異なる場合は大幅な変化とみなす
33
+ if (img1.width !== img2.width || img1.height !== img2.height) {
34
+ return { mismatchRatio: 1, diffSaved: false, skipped: false };
35
+ }
36
+ const { width, height } = img1;
37
+ const diff = new PNG({ width, height });
38
+ const numDiffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 });
39
+ const mismatchRatio = numDiffPixels / (width * height);
40
+ fs.writeFileSync(opts.diffPath, PNG.sync.write(diff));
41
+ return { mismatchRatio, diffSaved: true, skipped: false };
42
+ }
43
+ /**
44
+ * 現在のスクリーンショットをベースラインとして保存する。
45
+ */
46
+ export function saveBaseline(screenshotPath, baselineName, baselinesDir) {
47
+ if (!fs.existsSync(baselinesDir)) {
48
+ fs.mkdirSync(baselinesDir, { recursive: true });
49
+ }
50
+ const dest = path.join(baselinesDir, `${baselineName}.png`);
51
+ fs.copyFileSync(screenshotPath, dest);
52
+ return dest;
53
+ }
@@ -0,0 +1,9 @@
1
+ import type { TestContext } from "./context.js";
2
+ /**
3
+ * テスト本体をセルフヒール付きで実行するラッパー。
4
+ *
5
+ * SELF_HEAL=1 かつエラーが VisualRegressionError でない場合、
6
+ * performSelfHeal() で回復を試み fn() を再実行する。
7
+ */
8
+ export declare function withSelfHeal(ctx: TestContext, fn: () => Promise<void>): Promise<void>;
9
+ //# sourceMappingURL=self-heal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self-heal.d.ts","sourceRoot":"","sources":["../src/self-heal.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAgChD;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,WAAW,EAChB,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAaf"}
@@ -0,0 +1,51 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { frameworkConfig } from "./config.js";
4
+ import { VisualRegressionError } from "./context.js";
5
+ /**
6
+ * セルフヒール回復処理:
7
+ * 1. 余分なタブを閉じる
8
+ * 2. Stagehand キャッシュを削除して LLM に再推論させる
9
+ * 3. テスト対象ページをリロードしてクリーンな状態に戻す
10
+ */
11
+ async function performSelfHeal(ctx) {
12
+ // 余分なタブを閉じる
13
+ const extraPages = ctx.stagehand.context
14
+ .pages()
15
+ .filter((p) => p !== ctx.page);
16
+ for (const ep of extraPages) {
17
+ try {
18
+ await ep.close();
19
+ }
20
+ catch { }
21
+ }
22
+ // キャッシュを削除
23
+ const cacheDir = path.resolve(frameworkConfig.stagehand.cacheDir);
24
+ if (fs.existsSync(cacheDir)) {
25
+ fs.rmSync(cacheDir, { recursive: true });
26
+ fs.mkdirSync(cacheDir, { recursive: true });
27
+ }
28
+ // ページを再読み込み
29
+ await ctx.page.goto(ctx.originUrl);
30
+ await ctx.page.waitForLoadState("networkidle");
31
+ }
32
+ /**
33
+ * テスト本体をセルフヒール付きで実行するラッパー。
34
+ *
35
+ * SELF_HEAL=1 かつエラーが VisualRegressionError でない場合、
36
+ * performSelfHeal() で回復を試み fn() を再実行する。
37
+ */
38
+ export async function withSelfHeal(ctx, fn) {
39
+ try {
40
+ await fn();
41
+ }
42
+ catch (err) {
43
+ if (err instanceof VisualRegressionError)
44
+ throw err;
45
+ if (process.env.SELF_HEAL !== "1")
46
+ throw err;
47
+ console.log(" [self-heal] Test failed, performing recovery and retrying...");
48
+ await performSelfHeal(ctx);
49
+ await fn();
50
+ }
51
+ }
@@ -0,0 +1,16 @@
1
+ import { type TestContext } from "./context.js";
2
+ import { type Recorder } from "./recording.js";
3
+ /**
4
+ * Stagehand を初期化し、テスト実行に必要な環境を準備する。
5
+ * beforeAll() から呼び出す。
6
+ */
7
+ export declare function initStagehand(originUrl: string): Promise<{
8
+ ctx: TestContext;
9
+ recorder: Recorder;
10
+ }>;
11
+ /**
12
+ * 録画を停止し、ブラウザを閉じ、動画を生成する。
13
+ * afterAll() から呼び出す。
14
+ */
15
+ export declare function closeStagehand(ctx: TestContext, recorder: Recorder): Promise<void>;
16
+ //# sourceMappingURL=stagehand.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stagehand.d.ts","sourceRoot":"","sources":["../src/stagehand.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAIL,KAAK,QAAQ,EAEd,MAAM,gBAAgB,CAAC;AAGxB;;;GAGG;AACH,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9D,GAAG,EAAE,WAAW,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC,CA2CD;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,WAAW,EAChB,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -0,0 +1,55 @@
1
+ import { Stagehand } from "@browserbasehq/stagehand";
2
+ import fs from "fs";
3
+ import { frameworkConfig } from "./config.js";
4
+ import { createTestContext, FRAMES_DIR, RECORDING_DIR, SCREENSHOT_DIR, } from "./context.js";
5
+ import { createNoopRecorder, framesToVideo, hasFfmpegCommand, startRecording, } from "./recording.js";
6
+ import { createScreenshotHelper } from "./screenshot.js";
7
+ /**
8
+ * Stagehand を初期化し、テスト実行に必要な環境を準備する。
9
+ * beforeAll() から呼び出す。
10
+ */
11
+ export async function initStagehand(originUrl) {
12
+ // 出力ディレクトリを準備(前回分をクリア)
13
+ for (const dir of [SCREENSHOT_DIR, RECORDING_DIR]) {
14
+ if (fs.existsSync(dir))
15
+ fs.rmSync(dir, { recursive: true });
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+ const ffmpegAvailable = hasFfmpegCommand();
19
+ if (ffmpegAvailable) {
20
+ fs.mkdirSync(FRAMES_DIR, { recursive: true });
21
+ }
22
+ else {
23
+ console.info("ffmpeg not found: skip frame capture and video conversion");
24
+ }
25
+ const stagehand = new Stagehand({
26
+ env: "LOCAL",
27
+ model: frameworkConfig.stagehand.model,
28
+ cacheDir: frameworkConfig.stagehand.cacheDir,
29
+ localBrowserLaunchOptions: {
30
+ headless: frameworkConfig.browser.headless,
31
+ viewport: frameworkConfig.browser.viewport,
32
+ },
33
+ });
34
+ await stagehand.init();
35
+ const page = stagehand.context.pages()[0];
36
+ const screenshotFn = createScreenshotHelper(page, SCREENSHOT_DIR);
37
+ const recorder = ffmpegAvailable
38
+ ? startRecording(page, FRAMES_DIR, frameworkConfig.videoRecording.frameIntervalMs)
39
+ : createNoopRecorder();
40
+ const ctx = createTestContext(stagehand, page, recorder, screenshotFn, originUrl);
41
+ return { ctx, recorder };
42
+ }
43
+ /**
44
+ * 録画を停止し、ブラウザを閉じ、動画を生成する。
45
+ * afterAll() から呼び出す。
46
+ */
47
+ export async function closeStagehand(ctx, recorder) {
48
+ const totalFrames = await recorder.stop();
49
+ await ctx.stagehand.close();
50
+ if (totalFrames > 0) {
51
+ const safeInterval = Math.max(frameworkConfig.videoRecording.frameIntervalMs, 1);
52
+ const fps = Math.round(1000 / safeInterval);
53
+ framesToVideo(FRAMES_DIR, RECORDING_DIR, fps);
54
+ }
55
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@uzulla/voreux",
3
+ "version": "0.1.0",
4
+ "description": "Voreux: Stagehand + Vitest ベースの E2E テストフレームワーク",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "voreux": "./dist/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "vitest run"
26
+ },
27
+ "dependencies": {
28
+ "@browserbasehq/stagehand": "^3.0.8",
29
+ "pixelmatch": "^7.1.0",
30
+ "pngjs": "^7.0.0",
31
+ "zod": "^3.25.76"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.2.1",
35
+ "@types/pngjs": "^6.0.5",
36
+ "typescript": "^5.9.3",
37
+ "vitest": "^4.0.18"
38
+ }
39
+ }