mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: app e2e test (#162)
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: create-app-e2e-test
|
||||
description: Create app e2e tests for the Goose desktop app using the Tauri app test driver. Use when the user wants to generate, write, or verify UI tests that run against the live app.
|
||||
---
|
||||
|
||||
# Create App E2E Test
|
||||
|
||||
You are an AI agent that creates app e2e tests for the Goose desktop app using the Tauri app test driver.
|
||||
|
||||
## Goal
|
||||
|
||||
Given a test scenario in natural language, you will:
|
||||
|
||||
1. Explore the app using the test driver CLI to discover what's on screen
|
||||
2. Write a Vitest test file that verifies the scenario using stable selectors
|
||||
|
||||
**Do NOT read source code to understand the UI.** Do not read `.tsx`, `.ts`, or `.css` files to find elements. Use `snapshot` to discover what is on the page — that is your only method. The one exception: read source code only when you need to add a `data-testid` attribute.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Tauri app must already be running in dev mode with the app test driver enabled.
|
||||
|
||||
## Test Driver CLI
|
||||
|
||||
All exploration commands use the test driver client CLI:
|
||||
|
||||
```bash
|
||||
pnpm test-driver <action> [selector] [value]
|
||||
```
|
||||
|
||||
Available commands:
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `snapshot` | Get a text DOM of visible elements | `pnpm test-driver snapshot` |
|
||||
| `getText <selector>` | Get inner text of an element | `pnpm test-driver getText "h1"` |
|
||||
| `count <selector>` | Count matching elements | `pnpm test-driver count "button"` |
|
||||
| `click <selector>` | Click an element | `pnpm test-driver click "button"` |
|
||||
| `fill <selector> <value>` | Fill an input/textarea | `pnpm test-driver fill "textarea" "hello"` |
|
||||
| `keypress <selector> <key>` | Dispatch a keyboard event | `pnpm test-driver keypress "textarea" Enter` |
|
||||
| `waitForText <text>` | Wait for text to appear in body (30s default) | `pnpm test-driver waitForText "Success"` |
|
||||
| `scroll <direction>` | Scroll the page (up/down/top/bottom) | `pnpm test-driver scroll down` |
|
||||
| `screenshot [path]` | Take a screenshot | `pnpm test-driver screenshot test.png` |
|
||||
|
||||
### Snapshot Format
|
||||
|
||||
The `snapshot` command returns a simplified text DOM:
|
||||
|
||||
```
|
||||
[e1] input type="text" placeholder="Ask anything..."
|
||||
[t1] label "Name:"
|
||||
[e2] button "Send"
|
||||
[t2] span "Click me"
|
||||
[t3] h1 "Good afternoon"
|
||||
```
|
||||
|
||||
- `[eN]` — interactive elements (input, button, select, textarea, a) with auto-assigned `data-tid`
|
||||
- `[tN]` — visible text nodes
|
||||
- Hidden elements are excluded
|
||||
- Indentation shows DOM hierarchy
|
||||
|
||||
`data-tid` attributes (e.g., `[data-tid='e3']`) are assigned dynamically during each snapshot and **must not** be used in test files — they change between runs.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Explore
|
||||
|
||||
1. Navigate to home first, then `snapshot` to see the current page state.
|
||||
```bash
|
||||
pnpm test-driver click '[data-testid="nav-home"]'
|
||||
pnpm test-driver snapshot
|
||||
```
|
||||
Tests always start from the home screen (`useTestDriver()` navigates home in `beforeEach`), so exploration should too.
|
||||
|
||||
2. For each element you need to interact with or verify:
|
||||
- Identify it from the snapshot (e.g., `[e3] button "Send"`)
|
||||
- Pick a stable selector using the **Element Locating Strategy** below — never use `data-tid`
|
||||
- Use `count` with that stable selector to verify it matches exactly one element
|
||||
- Use `getText` to verify text content
|
||||
- Use `click`/`fill` to perform actions during exploration
|
||||
|
||||
3. After each action, run `snapshot` again — the DOM may have changed.
|
||||
|
||||
Example exploration session:
|
||||
```bash
|
||||
# 1. See what's on the page
|
||||
pnpm test-driver snapshot
|
||||
|
||||
# 2. Pick a selector for the element you need, verify it matches exactly 1
|
||||
pnpm test-driver count 'textarea[placeholder="Ask anything..."]'
|
||||
|
||||
# 3. Interact
|
||||
pnpm test-driver fill 'textarea[placeholder="Ask anything..."]' "hello world"
|
||||
|
||||
# 4. Snapshot again to see the result
|
||||
pnpm test-driver snapshot
|
||||
```
|
||||
|
||||
### Phase 2: Write the Test
|
||||
|
||||
Create a Vitest test file at `tests/app-e2e/<name>.test.ts`.
|
||||
|
||||
Use `useTestDriver()` from `tests/app-e2e/lib/setup.ts` to get a shared test driver connection with automatic teardown and screenshot-on-failure. See `tests/app-e2e/chat.test.ts` as a reference:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { useTestDriver } from "./lib/setup";
|
||||
|
||||
describe("<Feature>", () => {
|
||||
const testDriver = useTestDriver();
|
||||
|
||||
it("does something", async () => {
|
||||
const text = await testDriver.getText('[data-testid="greeting"]');
|
||||
expect(text).toContain("Good");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Test driver API methods available in tests:
|
||||
|
||||
- `testDriver.snapshot()` → text DOM string
|
||||
- `testDriver.getText(selector, { timeout? })` → inner text string
|
||||
- `testDriver.count(selector)` → number of matching elements
|
||||
- `testDriver.click(selector, { timeout? })` → clicks element
|
||||
- `testDriver.fill(selector, value, { timeout? })` → fills input/textarea
|
||||
- `testDriver.keypress(selector, key, { timeout? })` → dispatches keyboard event
|
||||
- `testDriver.waitForText(text, { selector?, timeout? })` → waits for text to appear (default: body, 30s)
|
||||
- `testDriver.scroll(direction)` → scrolls page ("up", "down", "top", "bottom")
|
||||
- `testDriver.screenshot(path)` → saves screenshot
|
||||
|
||||
All `timeout` options default to 5 seconds. `waitForText` defaults to 30 seconds.
|
||||
|
||||
### Phase 3: Verify the Test
|
||||
|
||||
Run the test to make sure it passes:
|
||||
|
||||
```bash
|
||||
pnpm test:app-e2e
|
||||
```
|
||||
|
||||
If it fails, use the test driver CLI to re-explore. The issue could be a wrong selector, an incorrect assertion, or a bug in the app implementation.
|
||||
|
||||
## Element Locating Strategy
|
||||
|
||||
**Always** verify uniqueness with `count` before using any selector in a test. If count > 1, the test will be flaky.
|
||||
|
||||
For each element, find a stable selector using this priority:
|
||||
|
||||
1. **`data-testid` (preferred)**: if the element already has a `data-testid`, use `'[data-testid="my-element"]'`.
|
||||
- Verify with `count` that it's unique
|
||||
|
||||
2. **Semantic selector**: combine attributes, hierarchy, or roles to target a specific element.
|
||||
- `'input[placeholder="Ask anything..."]'` — narrow by attribute value
|
||||
- `'.sidebar [role="navigation"] a'` — narrow by parent context
|
||||
- Always verify with `count` that the selector matches exactly 1 element
|
||||
|
||||
3. **Add a `data-testid` (last resort)**: if no stable selector exists, add a `data-testid` to the source code.
|
||||
- Names must be descriptive and include context (e.g., `home-greeting` not `greeting`, `sidebar-new-chat-button` not `button`)
|
||||
- Only add the `data-testid` attribute — do not change any other source code
|
||||
- Note the code change so it can be committed alongside the test
|
||||
|
||||
**Never** use `data-tid` attributes (`[data-tid='e3']`) in test files — they are assigned dynamically by `snapshot` and change between runs.
|
||||
|
||||
## Rules
|
||||
|
||||
- One test file per feature area (e.g., `home.test.ts`, `sidebar.test.ts`, `settings.test.ts`)
|
||||
- Keep test descriptions specific: "shows time-based greeting on home screen", not "home works"
|
||||
- Always check `count` === 1 for selectors before using them in assertions
|
||||
- Do not use `snapshot` in test assertions — it's for exploration only
|
||||
- The test driver automatically waits up to 5 seconds (configurable via `{ timeout }`) for elements to appear before `click`, `fill`, `getText`, and `keypress`
|
||||
- Use `waitForText` to wait for specific text content to appear (e.g., after submitting a form or waiting for an LLM response)
|
||||
- If the DOM updates after an action, run `snapshot` again to see the new state before writing assertions
|
||||
- Do not add comments in test files — the test descriptions and code should be self-explanatory
|
||||
@@ -32,6 +32,9 @@ Thumbs.db
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# App E2E screenshots
|
||||
tests/app-e2e/screenshots/
|
||||
|
||||
# Testing coverage
|
||||
coverage/
|
||||
|
||||
@@ -40,3 +43,6 @@ coverage/
|
||||
|
||||
# Hermit (toolchain manager cache)
|
||||
.hermit/
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"includes": [
|
||||
"**",
|
||||
"!src-tauri/gen",
|
||||
"!src-tauri/plugins/*/permissions/{schemas,autogenerated}",
|
||||
"!.agents",
|
||||
"!.worktrees",
|
||||
"!.claude/worktrees"
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ dev:
|
||||
fi
|
||||
fi
|
||||
|
||||
pnpm tauri dev --config "$TAURI_CONFIG"
|
||||
pnpm tauri dev --features app-test-driver --config "$TAURI_CONFIG"
|
||||
|
||||
# Start the desktop app with dev config
|
||||
dev-debug:
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"test:e2e:smoke": "pnpm build && playwright test --project=smoke",
|
||||
"test:e2e:personas": "pnpm build && playwright test --project=personas",
|
||||
"test:e2e:skills": "pnpm build && playwright test --project=skills",
|
||||
"test:e2e:drafts": "pnpm build && playwright test --project=drafts"
|
||||
"test:e2e:drafts": "pnpm build && playwright test --project=drafts",
|
||||
"test:app-e2e": "vitest run --config vitest.app-e2e.config.ts",
|
||||
"test-driver": "tsx tests/app-e2e/lib/test-driver-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -117,6 +119,7 @@
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^3.2.1"
|
||||
|
||||
Generated
+42
-14
@@ -269,7 +269,7 @@ importers:
|
||||
version: 15.5.13
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.6.0
|
||||
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
@@ -279,15 +279,18 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ~5.9.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.0.4
|
||||
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.4(@types/debug@4.1.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)
|
||||
version: 3.2.4(@types/debug@4.1.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -2565,6 +2568,9 @@ packages:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
get-tsconfig@4.13.7:
|
||||
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@@ -3370,6 +3376,9 @@ packages:
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
robust-predicates@3.0.3:
|
||||
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||
|
||||
@@ -3531,6 +3540,11 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
@@ -5466,7 +5480,7 @@ snapshots:
|
||||
|
||||
'@vercel/oidc@3.1.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
@@ -5474,7 +5488,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5486,13 +5500,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -6024,6 +6038,10 @@ snapshots:
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
get-tsconfig@4.13.7:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gsap@3.14.2: {}
|
||||
@@ -7183,6 +7201,8 @@ snapshots:
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
robust-predicates@3.0.3: {}
|
||||
|
||||
rollup@4.60.0:
|
||||
@@ -7378,6 +7398,13 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
get-tsconfig: 4.13.7
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
@@ -7503,13 +7530,13 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
vite-node@3.2.4(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -7524,7 +7551,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -7536,12 +7563,13 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0):
|
||||
vitest@3.2.4(@types/debug@4.1.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -7559,8 +7587,8 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite-node: 3.2.4(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.13
|
||||
|
||||
Generated
+78
@@ -1767,6 +1767,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-app-test-driver",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
@@ -2803,8 +2804,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
@@ -2832,6 +2863,41 @@ dependencies = [
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -4584,6 +4650,18 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-app-test-driver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-app-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = ">=2,<2.7"
|
||||
tauri-plugin-window-state = "2"
|
||||
@@ -50,5 +51,8 @@ keyring = { version = "3", features = ["windows-native"] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
|
||||
|
||||
[features]
|
||||
app-test-driver = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"window-state:allow-save-window-state",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"app-test-driver:default",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","core:webview:allow-set-webview-zoom"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$HOME/.goose/artifacts/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -344,6 +344,24 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:default",
|
||||
"markdownDescription": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:allow-driver-result",
|
||||
"markdownDescription": "Enables the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:deny-driver-result",
|
||||
"markdownDescription": "Denies the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -344,6 +344,24 @@
|
||||
"Identifier": {
|
||||
"description": "Permission identifier",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:default",
|
||||
"markdownDescription": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:allow-driver-result",
|
||||
"markdownDescription": "Enables the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "app-test-driver:deny-driver-result",
|
||||
"markdownDescription": "Denies the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`",
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "tauri-plugin-app-test-driver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
links = "tauri-plugin-app-test-driver"
|
||||
build = "build.rs"
|
||||
|
||||
[lib]
|
||||
name = "tauri_plugin_app_test_driver"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSResponder"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
@@ -0,0 +1,5 @@
|
||||
const COMMANDS: &[&str] = &["driver_result"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS).build();
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-driver-result"
|
||||
description = "Enables the driver_result command without any pre-configured scope."
|
||||
commands.allow = ["driver_result"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-driver-result"
|
||||
description = "Denies the driver_result command without any pre-configured scope."
|
||||
commands.deny = ["driver_result"]
|
||||
@@ -0,0 +1,43 @@
|
||||
## Default Permission
|
||||
|
||||
Default permissions for the app test driver plugin.
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
- `allow-driver-result`
|
||||
|
||||
## Permission Table
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`app-test-driver:allow-driver-result`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the driver_result command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`app-test-driver:deny-driver-result`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the driver_result command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,7 @@
|
||||
"$schema" = "schemas/schema.json"
|
||||
|
||||
[default]
|
||||
description = """
|
||||
Default permissions for the app test driver plugin.
|
||||
"""
|
||||
permissions = ["allow-driver-result"]
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PermissionFile",
|
||||
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default": {
|
||||
"description": "The default permission set for the plugin",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultPermission"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"set": {
|
||||
"description": "A list of permissions sets defined",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionSet"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"description": "A list of inlined permissions",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"DefaultPermission": {
|
||||
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"description": "A set of direct permissions grouped together under a new name.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does.",
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"description": "Allowed or denied commands when using this permission.",
|
||||
"default": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Commands"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"description": "Allowed or denied scoped when using this permission.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Scopes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands": {
|
||||
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Allowed command.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Denied command, which takes priority.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scopes": {
|
||||
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Value": {
|
||||
"description": "All supported ACL values.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents a null JSON value.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`bool`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Represents a valid ACL [`Number`].",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`String`].",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Represents a list of other [`Value`]s.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Number": {
|
||||
"description": "A valid ACL number.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents an [`i64`].",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`f64`].",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Target": {
|
||||
"description": "Platform target.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionKind": {
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Enables the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-driver-result",
|
||||
"markdownDescription": "Enables the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the driver_result command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-driver-result",
|
||||
"markdownDescription": "Denies the driver_result command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the app test driver plugin.\n\n#### This default permission set includes:\n\n- `allow-driver-result`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct TestCommand {
|
||||
action: String,
|
||||
selector: Option<String>,
|
||||
value: Option<String>,
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestResult {
|
||||
success: bool,
|
||||
data: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn driver_result(state: tauri::State<'_, DriverState>, value: String) {
|
||||
let mut result = state.pending_result.lock().unwrap();
|
||||
*result = Some(value);
|
||||
state.signal.notify_one();
|
||||
}
|
||||
|
||||
struct DriverState {
|
||||
command_lock: Mutex<()>,
|
||||
pending_result: Mutex<Option<String>>,
|
||||
signal: std::sync::Condvar,
|
||||
}
|
||||
|
||||
impl DriverState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
command_lock: Mutex::new(()),
|
||||
pending_result: Mutex::new(None),
|
||||
signal: std::sync::Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_result(&self, timeout_ms: u64) -> Option<String> {
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms + 1000);
|
||||
let guard = self.pending_result.lock().unwrap();
|
||||
let (mut guard, _) = self
|
||||
.signal
|
||||
.wait_timeout_while(guard, timeout, |result| result.is_none())
|
||||
.unwrap();
|
||||
guard.take()
|
||||
}
|
||||
|
||||
fn reset(&self) {
|
||||
*self.pending_result.lock().unwrap() = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_js_string(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('\'', "\\'")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
fn with_wait_for(selector: &str, action_js: &str, timeout_ms: u64) -> String {
|
||||
let escaped = escape_js_string(selector);
|
||||
format!(
|
||||
r#"(async function() {{
|
||||
const sel = "{escaped}";
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < {timeout_ms}) {{
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {{
|
||||
{action_js}
|
||||
}}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}}
|
||||
return "ERROR: timeout waiting for element: " + sel;
|
||||
}})()"#
|
||||
)
|
||||
}
|
||||
|
||||
fn build_js(cmd: &TestCommand) -> String {
|
||||
let timeout_ms = cmd.timeout.unwrap_or(5000);
|
||||
let inner_js = match cmd.action.as_str() {
|
||||
"snapshot" => r#"
|
||||
(function() {
|
||||
const result = [];
|
||||
let eIdx = 0;
|
||||
let tIdx = 0;
|
||||
|
||||
function isVisible(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.opacity !== '0';
|
||||
}
|
||||
|
||||
function isInteractive(tag) {
|
||||
return ['INPUT','BUTTON','SELECT','TEXTAREA','A'].includes(tag);
|
||||
}
|
||||
|
||||
function walk(node, depth) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = node.tagName;
|
||||
if (['SCRIPT','STYLE','META','LINK','NOSCRIPT'].includes(tag)) return;
|
||||
if (!isVisible(node)) return;
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
const tagLower = tag.toLowerCase();
|
||||
|
||||
if (isInteractive(tag)) {
|
||||
eIdx++;
|
||||
node.setAttribute('data-tid', 'e' + eIdx);
|
||||
let info = '[e' + eIdx + '] ' + tagLower;
|
||||
if (node.type) info += ' type="' + node.type + '"';
|
||||
if (node.placeholder) info += ' placeholder="' + node.placeholder + '"';
|
||||
if (node.value) info += ' value="' + node.value + '"';
|
||||
if (node.href) info += ' href="' + node.href + '"';
|
||||
const text = node.innerText?.trim();
|
||||
if (text && text.length < 100) info += ' "' + text + '"';
|
||||
result.push(indent + info);
|
||||
} else {
|
||||
const directText = Array.from(node.childNodes)
|
||||
.filter(n => n.nodeType === Node.TEXT_NODE)
|
||||
.map(n => n.textContent.trim())
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (directText && directText.length > 0 && directText.length < 200) {
|
||||
tIdx++;
|
||||
result.push(indent + '[t' + tIdx + '] ' + tagLower + ' "' + directText + '"');
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
walk(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(document.body, 0);
|
||||
return result.join('\n');
|
||||
})()
|
||||
"#
|
||||
.to_string(),
|
||||
"click" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("body");
|
||||
with_wait_for(sel, r#"el.click(); return "clicked";"#, timeout_ms)
|
||||
}
|
||||
"fill" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("input");
|
||||
let val = escape_js_string(cmd.value.as_deref().unwrap_or(""));
|
||||
with_wait_for(
|
||||
sel,
|
||||
&format!(
|
||||
r#"const proto = el instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
||||
setter.call(el, "{val}");
|
||||
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
return "filled";"#
|
||||
),
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
"keypress" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("body");
|
||||
let key = cmd.value.as_deref().unwrap_or("Enter");
|
||||
let escaped_key = escape_js_string(key);
|
||||
with_wait_for(
|
||||
sel,
|
||||
&format!(
|
||||
r#"const opts = {{ key: "{escaped_key}", code: "{escaped_key}", keyCode: "{escaped_key}" === "Enter" ? 13 : 0, bubbles: true, cancelable: true }};
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', opts));
|
||||
el.dispatchEvent(new KeyboardEvent('keypress', opts));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', opts));
|
||||
return "keypressed";"#
|
||||
),
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
"getText" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("body");
|
||||
with_wait_for(sel, "return el.innerText;", timeout_ms)
|
||||
}
|
||||
"waitForText" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("body");
|
||||
let text = escape_js_string(cmd.value.as_deref().unwrap_or(""));
|
||||
let escaped_sel = escape_js_string(sel);
|
||||
format!(
|
||||
r#"(async function() {{
|
||||
const sel = "{escaped_sel}";
|
||||
const text = "{text}";
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < {timeout_ms}) {{
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.innerText.includes(text)) {{
|
||||
return el.innerText;
|
||||
}}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}}
|
||||
return "ERROR: timeout waiting for text: " + text;
|
||||
}})()"#
|
||||
)
|
||||
}
|
||||
"count" => {
|
||||
let sel = cmd.selector.as_deref().unwrap_or("*");
|
||||
let escaped_sel = escape_js_string(sel);
|
||||
format!("String(document.querySelectorAll(\"{escaped_sel}\").length)")
|
||||
}
|
||||
"scroll" => {
|
||||
let direction = cmd.value.as_deref().unwrap_or("down");
|
||||
match direction {
|
||||
"up" => "window.scrollBy(0, -window.innerHeight); 'scrolled up'".to_string(),
|
||||
"top" => "window.scrollTo(0, 0); 'scrolled to top'".to_string(),
|
||||
"bottom" => {
|
||||
"window.scrollTo(0, document.body.scrollHeight); 'scrolled to bottom'"
|
||||
.to_string()
|
||||
}
|
||||
_ => "window.scrollBy(0, window.innerHeight); 'scrolled down'".to_string(),
|
||||
}
|
||||
}
|
||||
_ => format!("'unknown action: {}'", cmd.action),
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"
|
||||
(async function() {{
|
||||
try {{
|
||||
const result = await Promise.resolve({inner_js});
|
||||
await window.__TAURI_INTERNALS__.invoke('plugin:app-test-driver|driver_result', {{ value: String(result) }});
|
||||
}} catch(e) {{
|
||||
await window.__TAURI_INTERNALS__.invoke('plugin:app-test-driver|driver_result', {{ value: 'ERROR: ' + e.message }});
|
||||
}}
|
||||
}})();
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_ns_window_number<R: Runtime>(window: &WebviewWindow<R>) -> Option<u32> {
|
||||
let ns_window_ptr = window.ns_window().ok()?;
|
||||
let ns_window = unsafe { &*(ns_window_ptr as *const objc2_app_kit::NSWindow) };
|
||||
Some(ns_window.windowNumber() as u32)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn take_screenshot<R: Runtime>(window: &WebviewWindow<R>, path: &str) -> TestResult {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let window_id = match get_ns_window_number(window) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Failed to get window ID".into()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match std::process::Command::new("screencapture")
|
||||
.args(["-x", "-l", &window_id.to_string(), path])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => TestResult {
|
||||
success: true,
|
||||
data: Some(format!("Screenshot saved to {}", path)),
|
||||
error: None,
|
||||
},
|
||||
Ok(output) => TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(format!(
|
||||
"screencapture failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)),
|
||||
},
|
||||
Err(e) => TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(format!("Failed to run screencapture: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn start_server<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
std::thread::spawn(move || {
|
||||
let port =
|
||||
std::env::var("APP_TEST_DRIVER_PORT").unwrap_or_else(|_| "9999".to_string());
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let listener = match TcpListener::bind(&addr) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("[app-test-driver] Failed to bind {addr}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("[app-test-driver] Listening on {addr}");
|
||||
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let app = app_handle.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stream.try_clone().unwrap());
|
||||
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd: TestCommand = match serde_json::from_str(&line) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let resp = TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(format!("Invalid JSON: {e}")),
|
||||
};
|
||||
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("[app-test-driver] Received: {cmd:?}");
|
||||
|
||||
let window = match app.get_webview_window("main") {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
let resp = TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Main window not found".into()),
|
||||
};
|
||||
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let state = app.state::<DriverState>();
|
||||
let _command_guard = state.command_lock.lock().unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if cmd.action == "screenshot" {
|
||||
let path = cmd.value.as_deref().unwrap_or("screenshot.png");
|
||||
let resp = take_screenshot(&window, path);
|
||||
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
|
||||
continue;
|
||||
}
|
||||
|
||||
state.reset();
|
||||
|
||||
let js = build_js(&cmd);
|
||||
if let Err(e) = window.eval(&js) {
|
||||
let resp = TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(format!("eval failed: {e}")),
|
||||
};
|
||||
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
|
||||
continue;
|
||||
}
|
||||
|
||||
let timeout_ms = cmd.timeout.unwrap_or(5000);
|
||||
let result = state.wait_for_result(timeout_ms);
|
||||
let resp = match result {
|
||||
Some(data) if data.starts_with("ERROR:") => TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(data),
|
||||
},
|
||||
Some(data) => TestResult {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
},
|
||||
None => TestResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Timeout waiting for result".into()),
|
||||
},
|
||||
};
|
||||
|
||||
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("app-test-driver")
|
||||
.invoke_handler(tauri::generate_handler![driver_result])
|
||||
.setup(|app, _api| {
|
||||
app.manage(DriverState::new());
|
||||
start_server(app.clone());
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ pub fn run() {
|
||||
let acp_registry = Arc::new(AcpSessionRegistry::new());
|
||||
let acp_registry_for_exit = Arc::clone(&acp_registry);
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(log::LevelFilter::Debug)
|
||||
@@ -32,7 +32,12 @@ pub fn run() {
|
||||
)
|
||||
.manage(PersonaStore::new())
|
||||
.manage(GooseConfig::new())
|
||||
.manage(acp_registry)
|
||||
.manage(acp_registry);
|
||||
|
||||
#[cfg(feature = "app-test-driver")]
|
||||
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
|
||||
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::agents::list_personas,
|
||||
commands::agents::create_persona,
|
||||
|
||||
@@ -414,6 +414,7 @@ export function Sidebar({
|
||||
<button
|
||||
ref={homeRef}
|
||||
type="button"
|
||||
data-testid="nav-home"
|
||||
onClick={() => onNavigate?.("home")}
|
||||
onMouseEnter={onItemMouseEnter}
|
||||
title={collapsed ? t("navigation.home") : undefined}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { useTestDriver } from "./lib/setup";
|
||||
|
||||
describe("Chat", () => {
|
||||
const testDriver = useTestDriver();
|
||||
|
||||
it("returns formatted date when asked", async () => {
|
||||
await testDriver.fill(
|
||||
'textarea[placeholder*="Message Goose"]',
|
||||
'Show me the date of Jan 26 2025 in format of "dd-mm-yyyy"',
|
||||
);
|
||||
await testDriver.keypress(
|
||||
'textarea[placeholder*="Message Goose"]',
|
||||
"Enter",
|
||||
);
|
||||
|
||||
const bodyText = await testDriver.waitForText("26-01-2025", {
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(bodyText).toContain("26-01-2025");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeAll, beforeEach, afterAll, onTestFailed } from "vitest";
|
||||
import { type TestDriver, createTestDriver } from "./test-driver-client";
|
||||
|
||||
declare const __SCREENSHOT_DIR__: string;
|
||||
declare const __SCREENSHOT_ON_FAILURE__: boolean;
|
||||
|
||||
export const useTestDriver = (): TestDriver => {
|
||||
let inner: TestDriver;
|
||||
|
||||
const testDriver = new Proxy({} as TestDriver, {
|
||||
get(_target, prop) {
|
||||
if (!inner)
|
||||
throw new Error("Test driver not connected — is beforeAll running?");
|
||||
return inner[prop as keyof TestDriver];
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
inner = await createTestDriver();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
inner?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Navigate to home before each test for clean state
|
||||
await inner.click('[data-testid="nav-home"]');
|
||||
|
||||
if (__SCREENSHOT_ON_FAILURE__) {
|
||||
onTestFailed(async ({ task }) => {
|
||||
const name = task.name.replace(/\s+/g, "-").toLowerCase();
|
||||
const path = `${__SCREENSHOT_DIR__}/fail-${name}-${Date.now()}.png`;
|
||||
await inner.screenshot(path);
|
||||
console.log(`Screenshot saved: ${path}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return testDriver;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { createTestDriver } from "./test-driver-client";
|
||||
|
||||
const [action, selector, value] = process.argv.slice(2);
|
||||
|
||||
if (!action) {
|
||||
console.log(`Usage:
|
||||
test-driver snapshot
|
||||
test-driver getText "h1"
|
||||
test-driver count "button"
|
||||
test-driver click "[data-tid='e1']"
|
||||
test-driver fill "textarea" "hello"
|
||||
test-driver keypress "textarea" Enter
|
||||
test-driver waitForText "expected text"
|
||||
test-driver scroll down|up|top|bottom
|
||||
test-driver screenshot [output.png]`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const testDriver = await createTestDriver();
|
||||
let result: string | number;
|
||||
|
||||
if (action === "screenshot") {
|
||||
result = await testDriver.screenshot(
|
||||
selector || `tests/app-e2e/screenshots/screenshot-${Date.now()}.png`,
|
||||
);
|
||||
} else if (action === "fill") {
|
||||
result = await testDriver.fill(selector, value);
|
||||
} else if (action === "keypress") {
|
||||
result = await testDriver.keypress(selector, value);
|
||||
} else if (action === "waitForText") {
|
||||
result = await testDriver.waitForText(selector);
|
||||
} else if (action === "scroll") {
|
||||
result = await testDriver.scroll(selector);
|
||||
} else if (action === "count") {
|
||||
result = await testDriver.count(selector);
|
||||
} else if (action === "click") {
|
||||
result = await testDriver.click(selector);
|
||||
} else if (action === "getText") {
|
||||
result = await testDriver.getText(selector);
|
||||
} else if (action === "snapshot") {
|
||||
result = await testDriver.snapshot();
|
||||
} else {
|
||||
console.error(`Unknown action: ${action}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(result);
|
||||
testDriver.close();
|
||||
} catch (err) {
|
||||
console.error("Error:", (err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import net from "node:net";
|
||||
|
||||
const PORT = Number(process.env.APP_TEST_DRIVER_PORT) || 9999;
|
||||
|
||||
interface TestDriverCommand {
|
||||
action: string;
|
||||
selector?: string;
|
||||
value?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface TestDriverResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TestDriver {
|
||||
snapshot: () => Promise<string>;
|
||||
click: (selector?: string, options?: { timeout?: number }) => Promise<string>;
|
||||
fill: (
|
||||
selector: string,
|
||||
value: string,
|
||||
options?: { timeout?: number },
|
||||
) => Promise<string>;
|
||||
getText: (
|
||||
selector?: string,
|
||||
options?: { timeout?: number },
|
||||
) => Promise<string>;
|
||||
count: (selector: string) => Promise<number>;
|
||||
keypress: (
|
||||
selector?: string,
|
||||
key?: string,
|
||||
options?: { timeout?: number },
|
||||
) => Promise<string>;
|
||||
waitForText: (
|
||||
text: string,
|
||||
options?: { selector?: string; timeout?: number },
|
||||
) => Promise<string>;
|
||||
scroll: (direction?: string) => Promise<string>;
|
||||
screenshot: (path?: string) => Promise<string>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function send(socket: net.Socket, command: TestDriverCommand): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
|
||||
const cleanup = () => {
|
||||
socket.removeListener("data", onData);
|
||||
socket.removeListener("error", onError);
|
||||
socket.removeListener("close", onClose);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
if (data.includes("\n")) {
|
||||
cleanup();
|
||||
try {
|
||||
const parsed: TestDriverResult = JSON.parse(data.trim());
|
||||
if (parsed.success) {
|
||||
resolve(parsed.data ?? "");
|
||||
} else {
|
||||
reject(new Error(parsed.error || "Unknown test driver error"));
|
||||
}
|
||||
} catch (_e) {
|
||||
reject(new Error(`Invalid response: ${data}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Test driver socket error: ${err.message}`));
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error("Test driver socket closed before response was received"),
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("data", onData);
|
||||
socket.on("error", onError);
|
||||
socket.on("close", onClose);
|
||||
socket.write(`${JSON.stringify(command)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client connection to the Tauri app test driver.
|
||||
* Returns an object with methods for each test driver command.
|
||||
*/
|
||||
export async function createTestDriver({
|
||||
port = PORT,
|
||||
}: {
|
||||
port?: number;
|
||||
} = {}): Promise<TestDriver> {
|
||||
const socket = net.createConnection({ port, host: "127.0.0.1" });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.on("connect", resolve);
|
||||
socket.on("error", (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`Cannot connect to test driver on port ${port}. ` +
|
||||
`Is the Tauri app running with --features app-test-driver? (${err.message})`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
snapshot() {
|
||||
return send(socket, { action: "snapshot" });
|
||||
},
|
||||
click(selector?: string, options?: { timeout?: number }) {
|
||||
return send(socket, {
|
||||
action: "click",
|
||||
selector,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
},
|
||||
fill(selector: string, value: string, options?: { timeout?: number }) {
|
||||
return send(socket, {
|
||||
action: "fill",
|
||||
selector,
|
||||
value,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
},
|
||||
getText(selector?: string, options?: { timeout?: number }) {
|
||||
return send(socket, {
|
||||
action: "getText",
|
||||
selector,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
},
|
||||
count(selector: string) {
|
||||
return send(socket, { action: "count", selector }).then(Number);
|
||||
},
|
||||
keypress(selector?: string, key?: string, options?: { timeout?: number }) {
|
||||
return send(socket, {
|
||||
action: "keypress",
|
||||
selector,
|
||||
value: key,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
},
|
||||
waitForText(
|
||||
text: string,
|
||||
options?: { selector?: string; timeout?: number },
|
||||
) {
|
||||
return send(socket, {
|
||||
action: "waitForText",
|
||||
selector: options?.selector ?? "body",
|
||||
value: text,
|
||||
timeout: options?.timeout ?? 30000,
|
||||
});
|
||||
},
|
||||
scroll(direction?: string) {
|
||||
return send(socket, { action: "scroll", value: direction });
|
||||
},
|
||||
screenshot(path?: string) {
|
||||
return send(socket, { action: "screenshot", value: path });
|
||||
},
|
||||
close() {
|
||||
socket.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__SCREENSHOT_DIR__: JSON.stringify(
|
||||
process.env.SCREENSHOT_DIR || "tests/app-e2e/screenshots",
|
||||
),
|
||||
__SCREENSHOT_ON_FAILURE__: process.env.SCREENSHOT_ON_FAILURE !== "false",
|
||||
},
|
||||
test: {
|
||||
include: ["tests/app-e2e/**/*.test.ts"],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 10000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user