@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.
- package/README.md +79 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +287 -0
- package/dist/canvas.d.ts +47 -0
- package/dist/canvas.js +105 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +83 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +129 -0
- package/dist/deprecations.d.ts +24 -0
- package/dist/deprecations.js +59 -0
- package/dist/docker.d.ts +37 -0
- package/dist/docker.js +140 -0
- package/dist/fixtures.d.ts +29 -0
- package/dist/fixtures.js +112 -0
- package/dist/helpers.d.ts +100 -0
- package/dist/helpers.js +414 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/setup/base.d.ts +97 -0
- package/dist/setup/base.js +53 -0
- package/dist/setup/index.d.ts +14 -0
- package/dist/setup/index.js +126 -0
- package/dist/setup/v13.d.ts +28 -0
- package/dist/setup/v13.js +308 -0
- package/dist/setup/v14.d.ts +31 -0
- package/dist/setup/v14.js +421 -0
- package/dist/state.d.ts +139 -0
- package/dist/state.js +321 -0
- package/dist/systems/base.d.ts +48 -0
- package/dist/systems/base.js +57 -0
- package/dist/systems/dnd5e.d.ts +27 -0
- package/dist/systems/dnd5e.js +30 -0
- package/dist/systems/index.d.ts +13 -0
- package/dist/systems/index.js +20 -0
- package/dist/systems/pf2e.d.ts +25 -0
- package/dist/systems/pf2e.js +62 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.js +11 -0
- package/dist/ui/base.d.ts +35 -0
- package/dist/ui/base.js +43 -0
- package/dist/ui/dnd5e.d.ts +8 -0
- package/dist/ui/dnd5e.js +10 -0
- package/dist/ui/index.d.ts +45 -0
- package/dist/ui/index.js +72 -0
- package/dist/ui/tidy5e.d.ts +11 -0
- package/dist/ui/tidy5e.js +30 -0
- 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
|
+
}
|
package/dist/canvas.d.ts
ADDED
|
@@ -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,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();
|