@thefehr/foundry-playwright 0.2.1

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.
Files changed (48) hide show
  1. package/README.md +79 -0
  2. package/dist/auth.d.ts +18 -0
  3. package/dist/auth.js +287 -0
  4. package/dist/canvas.d.ts +47 -0
  5. package/dist/canvas.js +105 -0
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.js +83 -0
  8. package/dist/cli/init.d.ts +5 -0
  9. package/dist/cli/init.js +129 -0
  10. package/dist/deprecations.d.ts +24 -0
  11. package/dist/deprecations.js +59 -0
  12. package/dist/docker.d.ts +37 -0
  13. package/dist/docker.js +140 -0
  14. package/dist/fixtures.d.ts +29 -0
  15. package/dist/fixtures.js +112 -0
  16. package/dist/helpers.d.ts +100 -0
  17. package/dist/helpers.js +414 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +9 -0
  20. package/dist/setup/base.d.ts +97 -0
  21. package/dist/setup/base.js +53 -0
  22. package/dist/setup/index.d.ts +14 -0
  23. package/dist/setup/index.js +126 -0
  24. package/dist/setup/v13.d.ts +28 -0
  25. package/dist/setup/v13.js +308 -0
  26. package/dist/setup/v14.d.ts +31 -0
  27. package/dist/setup/v14.js +421 -0
  28. package/dist/state.d.ts +139 -0
  29. package/dist/state.js +321 -0
  30. package/dist/systems/base.d.ts +48 -0
  31. package/dist/systems/base.js +57 -0
  32. package/dist/systems/dnd5e.d.ts +27 -0
  33. package/dist/systems/dnd5e.js +30 -0
  34. package/dist/systems/index.d.ts +13 -0
  35. package/dist/systems/index.js +20 -0
  36. package/dist/systems/pf2e.d.ts +25 -0
  37. package/dist/systems/pf2e.js +62 -0
  38. package/dist/types/index.d.ts +18 -0
  39. package/dist/types/index.js +11 -0
  40. package/dist/ui/base.d.ts +35 -0
  41. package/dist/ui/base.js +43 -0
  42. package/dist/ui/dnd5e.d.ts +8 -0
  43. package/dist/ui/dnd5e.js +10 -0
  44. package/dist/ui/index.d.ts +45 -0
  45. package/dist/ui/index.js +72 -0
  46. package/dist/ui/tidy5e.d.ts +11 -0
  47. package/dist/ui/tidy5e.js +30 -0
  48. package/package.json +67 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @thefehr/foundry-playwright
2
+
3
+ A robust, multi-version E2E testing library for FoundryVTT modules and systems, powered by Playwright.
4
+
5
+ ## Status
6
+
7
+ This repository is currently in the **Extraction & Initialization** phase. Detailed documentation can be found in the `docs/` directory.
8
+
9
+ ## Documentation
10
+
11
+ ### Architecture & Design
12
+
13
+ - [Authentication & World Selection](docs/architecture/auth-and-world.md)
14
+ - [State Manipulation Fixtures](docs/architecture/state-manipulation.md)
15
+ - [Canvas Interaction Utilities](docs/architecture/canvas-interaction.md)
16
+ - [System Agnosticism & Configuration](docs/architecture/system-agnosticism.md)
17
+ - [Multi-Version Support (V13 & V14)](docs/architecture/multi-version-support.md)
18
+ - [Docker Test Orchestrator for Developers](docs/architecture/docker-orchestrator.md)
19
+
20
+ ### Plans & RFCs
21
+
22
+ - [RFC 0001: Main Extraction Plan](docs/rfcs/0001-extraction-plan.md)
23
+ - [Extraction & Integration Strategy](docs/rfcs/extraction-strategy.md)
24
+ - [Continuous Verification & Release Tracking](docs/rfcs/continuous-verification.md)
25
+ - [Roadmap: Features & Helper Functions](docs/rfcs/roadmap-and-features.md)
26
+
27
+ ## Core Features
28
+
29
+ - **Multi-Version Support:** Built-in adapters for FoundryVTT V13 and V14.
30
+ - **Docker Orchestration:** Automated setup and teardown of version-specific Foundry instances via CLI or programmatic orchestrator.
31
+ - **State Manipulation:** Fast, UI-less data injection via direct Foundry API and socket calls (`createActor`, `updateDocument`, `grantCurrency`).
32
+ - **UI Helpers:** Robust tab switching, aggressive tour suppression, and dialog automation.
33
+
34
+ ## Getting Started
35
+
36
+ ### Quick Start (Initialization)
37
+
38
+ The easiest way to get started is by using the CLI to bootstrap your project:
39
+
40
+ ```bash
41
+ # Install the library
42
+ npm install --save-dev @thefehr/foundry-playwright
43
+
44
+ # Initialize the test suite
45
+ npx foundry-playwright init
46
+ ```
47
+
48
+ This will create a `playwright.config.ts`, an `e2e` directory with a sample test, and add a `test:e2e` script to your `package.json`.
49
+
50
+ ### Writing Your First Test
51
+
52
+ Use the `useFoundry` helper to handle the complex authentication and world setup:
53
+
54
+ ```typescript
55
+ import { test, expect, useFoundry } from "@thefehr/foundry-playwright";
56
+
57
+ // Automatically boots Foundry and sets up the environment
58
+ useFoundry(test, {
59
+ worldId: "test-world",
60
+ systemId: "dnd5e",
61
+ moduleId: "my-module-id",
62
+ });
63
+
64
+ test("Foundry is ready", async ({ page }) => {
65
+ await page.goto("/");
66
+ await expect(page).toHaveTitle(/Foundry VTT/);
67
+ });
68
+ ```
69
+
70
+ ### Running Tests
71
+
72
+ ```bash
73
+ # Run tests with a Docker-orchestrated Foundry instance
74
+ npm run test:e2e
75
+ ```
76
+
77
+ For more detailed instructions, see the [Migration Guide](docs/getting-started/migration-guide.md).
78
+
79
+ ## Core Features
package/dist/auth.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Navigates from within a world or the join screen back to the setup screen.
4
+ * Implements RFC 0008 transition logic.
5
+ */
6
+ export declare function returnToSetup(page: Page, adminPassword?: string, _version?: string | number): Promise<void>;
7
+ /**
8
+ * Performs full end-to-end setup of a Foundry VTT instance.
9
+ */
10
+ export declare function foundrySetup(page: Page, config?: any): Promise<void>;
11
+ /**
12
+ * Performs teardown of a Foundry VTT world.
13
+ */
14
+ export declare function foundryTeardown(page: Page, config: any): Promise<void>;
15
+ /**
16
+ * Logs into a Foundry VTT world as a specific user.
17
+ */
18
+ export declare function loginAs(page: Page, userName: string, password?: string): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,287 @@
1
+ import { disableTour, waitForReady, validateStack } from "./helpers.js";
2
+ import { getSetupAdapter } from "./setup/index.js";
3
+ /**
4
+ * Navigates from within a world or the join screen back to the setup screen.
5
+ * Implements RFC 0008 transition logic.
6
+ */
7
+ export async function returnToSetup(page, adminPassword, _version) {
8
+ console.log("[returnToSetup] Returning to setup screen...");
9
+ let maxAttempts = 3;
10
+ for (let i = 0; i < maxAttempts; i++) {
11
+ const url = page.url();
12
+ console.log(`[returnToSetup] Attempt ${i + 1}. Current URL: ${url}`);
13
+ if (url.includes("/setup")) {
14
+ // Check if we are actually on setup or just redirected to setup login
15
+ const setupPwInput = page.locator('input[name="adminPassword"]');
16
+ if (await setupPwInput.isVisible()) {
17
+ console.log("[returnToSetup] Admin login required on /setup.");
18
+ await setupPwInput.fill(adminPassword || process.env.FOUNDRY_ADMIN_PASSWORD || "password");
19
+ await page
20
+ .locator('button[type="submit"], button:has-text("Log In")')
21
+ .first()
22
+ .evaluate((el) => el.click());
23
+ await page
24
+ .waitForURL((u) => u.pathname.includes("/setup"), { timeout: 10000 })
25
+ .catch(() => null);
26
+ await page.waitForLoadState("networkidle");
27
+ }
28
+ // Definitively check for setup application root
29
+ const isSetup = await page.evaluate(() => !!document.querySelector("foundry-app#setup") ||
30
+ document.body.classList.contains("setup"));
31
+ if (isSetup) {
32
+ console.log("[returnToSetup] Successfully reached Setup screen.");
33
+ return;
34
+ }
35
+ }
36
+ if (url.includes("/auth")) {
37
+ console.log("[returnToSetup] On /auth screen. Logging in...");
38
+ const pwInput = page.locator('input[name="adminPassword"]');
39
+ if (await pwInput.isVisible()) {
40
+ await pwInput.fill(adminPassword || process.env.FOUNDRY_ADMIN_PASSWORD || "password");
41
+ await page
42
+ .locator('button[type="submit"], button:has-text("Log In")')
43
+ .first()
44
+ .evaluate((el) => el.click());
45
+ // Wait for setup root OR url change
46
+ await Promise.race([
47
+ page.waitForURL((u) => u.pathname.includes("/setup"), { timeout: 20000 }),
48
+ page.waitForSelector("foundry-app#setup, body.setup", { timeout: 20000 }),
49
+ ]).catch(() => null);
50
+ await page.waitForLoadState("networkidle");
51
+ }
52
+ continue;
53
+ }
54
+ if (url.includes("/join")) {
55
+ console.log("[returnToSetup] On /join screen. Attempting Shutdown...");
56
+ // Check for V14 admin-gated shutdown form
57
+ const shutdownForm = page.locator("#join-game-setup");
58
+ const shutdownInput = shutdownForm.locator('input[name="adminPassword"]');
59
+ if (await shutdownInput.isVisible()) {
60
+ console.log("[returnToSetup] V14 Shutdown form detected. Filling password...");
61
+ await shutdownInput.fill(adminPassword || process.env.FOUNDRY_ADMIN_PASSWORD || "password");
62
+ await shutdownForm
63
+ .locator('button[type="submit"]')
64
+ .first()
65
+ .evaluate((el) => el.click());
66
+ await page.waitForTimeout(5000); // Allow time for shutdown
67
+ }
68
+ else {
69
+ // Standard return or direct navigation
70
+ const returnBtn = page.locator('button:has-text("Return to Setup"), button[name="shutdown"]');
71
+ if (await returnBtn.isVisible()) {
72
+ await returnBtn.evaluate((el) => el.click());
73
+ }
74
+ else {
75
+ await page.goto("/setup").catch(() => null);
76
+ }
77
+ }
78
+ await page.waitForLoadState("networkidle");
79
+ continue;
80
+ }
81
+ if (url.includes("/game") || url.includes("/players")) {
82
+ console.log("[returnToSetup] Inside World. Attempting to logout/shutdown...");
83
+ await page
84
+ .evaluate(() => {
85
+ // @ts-ignore
86
+ if (typeof game !== "undefined" && game.shutDown)
87
+ game.shutDown();
88
+ else
89
+ window.location.href = "/setup";
90
+ })
91
+ .catch(() => null);
92
+ await page.waitForTimeout(3000);
93
+ await page.waitForLoadState("networkidle");
94
+ continue;
95
+ }
96
+ // Default: try direct jump
97
+ console.log(`[returnToSetup] Navigating to /setup...`);
98
+ await page.goto("/setup").catch(() => null);
99
+ await page.waitForLoadState("networkidle");
100
+ }
101
+ }
102
+ /**
103
+ * Performs full end-to-end setup of a Foundry VTT instance.
104
+ */
105
+ export async function foundrySetup(page, config = {}) {
106
+ const SYSTEM_LABELS = {
107
+ dnd5e: "D&D 5th Edition",
108
+ pf2e: "Pathfinder 2e",
109
+ pf1: "Pathfinder 1st Edition",
110
+ swade: "Savage Worlds Adventure Edition",
111
+ worldbuilding: "Simple Worldbuilding",
112
+ dungeonworld: "Dungeon World",
113
+ };
114
+ const { worldId, systemId = process.env.FOUNDRY_SYSTEM_ID || "dnd5e", systemManifest, moduleId, moduleManifest, adminPassword = process.env.FOUNDRY_ADMIN_PASSWORD || process.env.FOUNDRY_ADMIN_KEY, userName = "Gamemaster", password = "", createWorld = true, deleteIfExists = true, version = process.env.FOUNDRY_VERSION, } = config;
115
+ const systemLabel = config.systemLabel || SYSTEM_LABELS[systemId] || systemId;
116
+ console.log(`[foundrySetup] Starting setup for world: ${worldId} (System: ${systemId})`);
117
+ let done = false;
118
+ let maxAttempts = 5;
119
+ for (let attempt = 1; attempt <= maxAttempts && !done; attempt++) {
120
+ if (page.url() === "about:blank")
121
+ await page.goto("/");
122
+ await disableTour(page);
123
+ await page.waitForLoadState("networkidle");
124
+ const url = page.url();
125
+ // 0. World Lock check
126
+ if (url.includes("/join") || url.includes("/game") || url.includes("/players")) {
127
+ await returnToSetup(page, adminPassword, version);
128
+ continue;
129
+ }
130
+ // 1. License Screen
131
+ if (url.endsWith("/license") || url.includes("/license#")) {
132
+ const adapter = await getSetupAdapter(page, version);
133
+ await adapter.handleEULA(page);
134
+ continue;
135
+ }
136
+ // 2. Admin Auth Screen
137
+ if (url.endsWith("/auth") ||
138
+ url.includes("/auth#") ||
139
+ (url.includes("/setup") && (await page.locator('input[name="adminPassword"]').isVisible()))) {
140
+ console.log("[foundrySetup] Admin login required.");
141
+ const pwInput = page.locator('input[name="adminPassword"]');
142
+ if (await pwInput.isVisible()) {
143
+ await pwInput.fill(adminPassword);
144
+ await page
145
+ .locator('button[type="submit"], button:has-text("Log In")')
146
+ .first()
147
+ .evaluate((el) => el.click());
148
+ await page
149
+ .waitForURL((u) => u.pathname.includes("/setup"), { timeout: 15000 })
150
+ .catch(() => null);
151
+ await page.waitForLoadState("networkidle");
152
+ }
153
+ continue;
154
+ }
155
+ // 3. Setup Screen
156
+ if (url.endsWith("/setup") || url.includes("/setup#")) {
157
+ console.log("[foundrySetup] On setup screen. Proceeding with configuration...");
158
+ const adapter = await getSetupAdapter(page, version);
159
+ // Aggressively clear Usage Data/Sharing dialogs (Shadow DOM included)
160
+ await page.evaluate(() => {
161
+ document.querySelectorAll("dialog, .application, foundry-app").forEach((d) => {
162
+ const text = d.textContent?.toLowerCase() || "";
163
+ if ((text.includes("usage data") || text.includes("sharing")) &&
164
+ !text.includes("license")) {
165
+ if (d.tagName.toLowerCase() === "dialog")
166
+ d.close?.();
167
+ d.remove();
168
+ }
169
+ });
170
+ });
171
+ // MANDATORY ORDER: Install system FIRST so Worlds tab is enabled in V14
172
+ if (systemManifest) {
173
+ await adapter.installSystemFromManifest(page, systemManifest);
174
+ }
175
+ else if (systemId) {
176
+ await adapter.installSystem(page, systemId, systemLabel);
177
+ }
178
+ // 4. Module Installation
179
+ if (moduleManifest) {
180
+ await adapter.installModuleFromManifest(page, moduleManifest);
181
+ }
182
+ else if (moduleId) {
183
+ const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
184
+ await adapter.installModules(page, moduleIds);
185
+ }
186
+ // 5. World Management (NOW SAFE in V14 as system exists)
187
+ if (deleteIfExists)
188
+ await adapter.deleteWorldIfExists(page, worldId);
189
+ if (createWorld) {
190
+ await adapter.createWorld(page, worldId, systemLabel, systemId);
191
+ // Final redirection check
192
+ if (page.url().includes("/game") ||
193
+ page.url().includes("/join") ||
194
+ page.url().includes("/players")) {
195
+ done = true;
196
+ }
197
+ else {
198
+ console.log(`[foundrySetup] Manually launching world "${worldId}"...`);
199
+ await adapter.switchTab(page, "Worlds");
200
+ const worldBox = page
201
+ .locator(`[data-package-id="${worldId}"], [data-module-id="${worldId}"]`)
202
+ .first();
203
+ const launchBtn = worldBox
204
+ .locator('[data-action="worldLaunch"], button:has-text("Launch")')
205
+ .first();
206
+ await launchBtn.evaluate((el) => el.click());
207
+ done = true;
208
+ }
209
+ }
210
+ else {
211
+ done = true;
212
+ }
213
+ }
214
+ }
215
+ if (!done)
216
+ throw new Error(`Failed to reach setup or game screen after ${maxAttempts} attempts.`);
217
+ // 6. Final Join and Game Ready
218
+ await page.waitForURL((u) => u.pathname.includes("/join") ||
219
+ u.pathname.includes("/game") ||
220
+ u.pathname.includes("/players"), { timeout: 60000 });
221
+ if (page.url().includes("/join")) {
222
+ console.log(`[foundrySetup] On join screen. Logging in as "${userName}"...`);
223
+ await page.locator('select[name="userid"]').selectOption({ label: userName });
224
+ if (password)
225
+ await page.locator('input[name="password"]').fill(password);
226
+ await page
227
+ .locator('button[name="join"]')
228
+ .evaluate((el) => el.click());
229
+ await page.waitForURL(/\/game/, { timeout: 60000 });
230
+ }
231
+ console.log("[foundrySetup] Waiting for game to be ready...");
232
+ await waitForReady(page);
233
+ // RFC 0008: Validate the stack against the registry
234
+ await validateStack(page, version).catch(() => null);
235
+ // 7. Module Activation via Server-Side Settings (RFC 0008 strategy)
236
+ if (moduleId) {
237
+ const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
238
+ console.log(`[foundrySetup] Activating modules via server settings: ${moduleIds.join(", ")}`);
239
+ await page.evaluate(async (ids) => {
240
+ // @ts-ignore
241
+ const current = game.settings.get("core", "moduleConfiguration") || {};
242
+ let changed = false;
243
+ ids.forEach((id) => {
244
+ if (!current[id]) {
245
+ current[id] = true;
246
+ changed = true;
247
+ }
248
+ });
249
+ if (changed) {
250
+ // @ts-ignore
251
+ await game.settings.set("core", "moduleConfiguration", current);
252
+ // @ts-ignore
253
+ game.socket.emit("reload");
254
+ window.location.reload();
255
+ }
256
+ }, moduleIds);
257
+ await page.waitForLoadState("networkidle");
258
+ await waitForReady(page);
259
+ }
260
+ }
261
+ /**
262
+ * Performs teardown of a Foundry VTT world.
263
+ */
264
+ export async function foundryTeardown(page, config) {
265
+ const { worldId, adminPassword = process.env.FOUNDRY_ADMIN_PASSWORD || process.env.FOUNDRY_ADMIN_KEY, version = process.env.FOUNDRY_VERSION, } = config;
266
+ console.log("[foundryTeardown] Starting teardown...");
267
+ await returnToSetup(page, adminPassword, version).catch(() => null);
268
+ await page.waitForLoadState("networkidle");
269
+ const adapter = await getSetupAdapter(page, version);
270
+ await disableTour(page);
271
+ await adapter.deleteWorldIfExists(page, worldId);
272
+ console.log("[foundryTeardown] Teardown complete.");
273
+ }
274
+ /**
275
+ * Logs into a Foundry VTT world as a specific user.
276
+ */
277
+ export async function loginAs(page, userName, password) {
278
+ if (!page.url().includes("/join"))
279
+ await page.goto("/join");
280
+ await page.waitForLoadState("networkidle");
281
+ await page.locator('select[name="userid"]').selectOption({ label: userName });
282
+ if (password)
283
+ await page.locator('input[name="password"]').fill(password);
284
+ await page.locator('button[name="join"]').evaluate((el) => el.click());
285
+ await page.waitForURL(/\/game/, { timeout: 60000 });
286
+ await waitForReady(page);
287
+ }
@@ -0,0 +1,47 @@
1
+ import { Page } from "@playwright/test";
2
+ /**
3
+ * Utilities for interacting with the Foundry VTT WebGL Canvas.
4
+ */
5
+ export declare class FoundryCanvas {
6
+ private page;
7
+ constructor(page: Page);
8
+ /**
9
+ * Converts grid coordinates (row, col) to viewport pixels.
10
+ * @param x The grid X coordinate (column).
11
+ * @param y The grid Y coordinate (row).
12
+ */
13
+ gridToPixels(x: number, y: number): Promise<{
14
+ x: number;
15
+ y: number;
16
+ }>;
17
+ /**
18
+ * Clicks on a specific token by its ID.
19
+ * @param tokenId The ID of the token.
20
+ */
21
+ clickToken(tokenId: string): Promise<void>;
22
+ /**
23
+ * Double-clicks on a specific token by its ID (usually opens sheet).
24
+ * @param tokenId The ID of the token.
25
+ */
26
+ doubleClickToken(tokenId: string): Promise<void>;
27
+ /**
28
+ * Drags a token from its current position to a target grid coordinate.
29
+ * @param tokenId The ID of the token to drag.
30
+ * @param targetX The target grid X coordinate.
31
+ * @param targetY The target grid Y coordinate.
32
+ */
33
+ dragToken(tokenId: string, targetX: number, targetY: number): Promise<void>;
34
+ /**
35
+ * Right-clicks on the canvas at a specific grid coordinate.
36
+ */
37
+ rightClickGrid(x: number, y: number): Promise<void>;
38
+ /**
39
+ * Targets a token (simulates the 'T' key).
40
+ * @param tokenId The ID of the token to target.
41
+ */
42
+ targetToken(tokenId: string): Promise<void>;
43
+ /**
44
+ * Internal helper to get the viewport pixels for a token's center.
45
+ */
46
+ private getTokenCanvasPosition;
47
+ }
package/dist/canvas.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Utilities for interacting with the Foundry VTT WebGL Canvas.
3
+ */
4
+ export class FoundryCanvas {
5
+ page;
6
+ constructor(page) {
7
+ this.page = page;
8
+ }
9
+ /**
10
+ * Converts grid coordinates (row, col) to viewport pixels.
11
+ * @param x The grid X coordinate (column).
12
+ * @param y The grid Y coordinate (row).
13
+ */
14
+ async gridToPixels(x, y) {
15
+ return this.page.evaluate(({ x, y }) => {
16
+ // @ts-ignore
17
+ const canvas = window.canvas;
18
+ if (!canvas || !canvas.ready)
19
+ throw new Error("Canvas is not ready.");
20
+ // Calculate pixels using Foundry's coordinate system
21
+ // Note: This accounts for padding and scale
22
+ const pixels = canvas.grid.getTopLeft(x, y);
23
+ const center = canvas.grid.getCenter(pixels[0], pixels[1]);
24
+ // Convert canvas coordinates to global (viewport) pixels
25
+ const global = canvas.stage.worldTransform.apply({ x: center[0], y: center[1] });
26
+ // Account for the canvas element's position on the page
27
+ const rect = document.getElementById("canvas")?.getBoundingClientRect();
28
+ if (!rect)
29
+ throw new Error("Canvas element not found.");
30
+ return {
31
+ x: global.x + rect.left,
32
+ y: global.y + rect.top,
33
+ };
34
+ }, { x, y });
35
+ }
36
+ /**
37
+ * Clicks on a specific token by its ID.
38
+ * @param tokenId The ID of the token.
39
+ */
40
+ async clickToken(tokenId) {
41
+ const coords = await this.getTokenCanvasPosition(tokenId);
42
+ await this.page.mouse.click(coords.x, coords.y);
43
+ }
44
+ /**
45
+ * Double-clicks on a specific token by its ID (usually opens sheet).
46
+ * @param tokenId The ID of the token.
47
+ */
48
+ async doubleClickToken(tokenId) {
49
+ const coords = await this.getTokenCanvasPosition(tokenId);
50
+ await this.page.mouse.click(coords.x, coords.y, { clickCount: 2 });
51
+ }
52
+ /**
53
+ * Drags a token from its current position to a target grid coordinate.
54
+ * @param tokenId The ID of the token to drag.
55
+ * @param targetX The target grid X coordinate.
56
+ * @param targetY The target grid Y coordinate.
57
+ */
58
+ async dragToken(tokenId, targetX, targetY) {
59
+ const start = await this.getTokenCanvasPosition(tokenId);
60
+ const end = await this.gridToPixels(targetX, targetY);
61
+ await this.page.mouse.move(start.x, start.y);
62
+ await this.page.mouse.down();
63
+ await this.page.mouse.move(end.x, end.y, { steps: 10 });
64
+ await this.page.mouse.up();
65
+ }
66
+ /**
67
+ * Right-clicks on the canvas at a specific grid coordinate.
68
+ */
69
+ async rightClickGrid(x, y) {
70
+ const coords = await this.gridToPixels(x, y);
71
+ await this.page.mouse.click(coords.x, coords.y, { button: "right" });
72
+ }
73
+ /**
74
+ * Targets a token (simulates the 'T' key).
75
+ * @param tokenId The ID of the token to target.
76
+ */
77
+ async targetToken(tokenId) {
78
+ const coords = await this.getTokenCanvasPosition(tokenId);
79
+ await this.page.mouse.move(coords.x, coords.y);
80
+ await this.page.keyboard.press("t");
81
+ }
82
+ /**
83
+ * Internal helper to get the viewport pixels for a token's center.
84
+ */
85
+ async getTokenCanvasPosition(tokenId) {
86
+ return this.page.evaluate((id) => {
87
+ // @ts-ignore
88
+ const token = window.canvas.tokens.get(id);
89
+ if (!token)
90
+ throw new Error(`Token ${id} not found on canvas.`);
91
+ // Get center in canvas coordinates
92
+ const center = token.center;
93
+ // Convert to global pixels
94
+ // @ts-ignore
95
+ const global = window.canvas.stage.worldTransform.apply(center);
96
+ const rect = document.getElementById("canvas")?.getBoundingClientRect();
97
+ if (!rect)
98
+ throw new Error("Canvas element not found.");
99
+ return {
100
+ x: global.x + rect.left,
101
+ y: global.y + rect.top,
102
+ };
103
+ }, tokenId);
104
+ }
105
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { DockerFoundryOrchestrator } from "../docker.js";
4
+ import { initAction } from "./init.js";
5
+ import { execSync } from "child_process";
6
+ import fs from "fs";
7
+ import path from "path";
8
+ const program = new Command();
9
+ program
10
+ .name("foundry-playwright")
11
+ .description("CLI for Foundry VTT E2E testing with Playwright")
12
+ .version("0.1.0");
13
+ program.command("init").description("Initialize a new Foundry E2E test project").action(initAction);
14
+ program
15
+ .command("test")
16
+ .description("Run E2E tests with an optional Docker-orchestrated Foundry instance")
17
+ .option("-v, --version <version>", "Foundry VTT version", process.env.FOUNDRY_VERSION || "13")
18
+ .option("-s, --system <id>", "System ID", process.env.FOUNDRY_SYSTEM_ID || "dnd5e")
19
+ .option("--docker", "Use Docker for the Foundry instance")
20
+ .option("--update-registry", "Update verified-versions.json on success")
21
+ .option("--playwright <command>", "Playwright command to run", "npx playwright test")
22
+ .action(async (options) => {
23
+ const { version, system, docker, updateRegistry, playwright } = options;
24
+ let orchestrator = null;
25
+ try {
26
+ if (docker) {
27
+ const tmpDataDir = path.join(process.cwd(), ".foundry_test_data", `.foundry_data_tmp_${Date.now()}`);
28
+ orchestrator = new DockerFoundryOrchestrator({
29
+ version,
30
+ adminKey: process.env.FOUNDRY_ADMIN_KEY || "password",
31
+ dataDir: tmpDataDir,
32
+ });
33
+ // Auto-inject local modules if they exist in e2e/ (common pattern in this repo)
34
+ const e2ePath = path.join(process.cwd(), "e2e");
35
+ if (fs.existsSync(e2ePath)) {
36
+ const items = fs.readdirSync(e2ePath);
37
+ for (const item of items) {
38
+ const itemPath = path.join(e2ePath, item);
39
+ if (fs.statSync(itemPath).isDirectory() &&
40
+ fs.existsSync(path.join(itemPath, "module.json"))) {
41
+ const modulesDir = path.join(tmpDataDir, "Data", "modules", item);
42
+ fs.mkdirSync(modulesDir, { recursive: true });
43
+ fs.cpSync(itemPath, modulesDir, { recursive: true });
44
+ }
45
+ }
46
+ }
47
+ const url = await orchestrator.start();
48
+ process.env.FOUNDRY_URL = url;
49
+ }
50
+ process.env.FOUNDRY_VERSION = version;
51
+ process.env.FOUNDRY_SYSTEM_ID = system;
52
+ console.log(`Running: ${playwright}`);
53
+ execSync(playwright, { stdio: "inherit", env: process.env });
54
+ if (updateRegistry) {
55
+ // Logic to update verified-versions.json
56
+ console.log("Updating registry...");
57
+ const registryPath = path.join(process.cwd(), "verified-versions.json");
58
+ if (fs.existsSync(registryPath)) {
59
+ const registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
60
+ registry.pending = registry.pending.filter((v) => v.version !== version);
61
+ if (!registry.verified.find((v) => v.version === version)) {
62
+ registry.verified.push({
63
+ version,
64
+ timestamp: new Date().toISOString(),
65
+ status: "stable",
66
+ notes: `Verified with CLI.`,
67
+ });
68
+ }
69
+ fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
70
+ }
71
+ }
72
+ }
73
+ catch (error) {
74
+ console.error(`Error: ${error.message}`);
75
+ process.exit(1);
76
+ }
77
+ finally {
78
+ if (orchestrator) {
79
+ await orchestrator.stopAndRemove();
80
+ }
81
+ }
82
+ });
83
+ program.parse();
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Action for the 'init' CLI command.
3
+ * Bootstraps a new Foundry E2E test project.
4
+ */
5
+ export declare function initAction(): Promise<void>;