skopix 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +65 -0
- package/.github/workflows/docker.yml +78 -0
- package/cli/commands/agent.js +378 -0
- package/cli/commands/config.js +67 -0
- package/cli/commands/dashboard.js +3524 -0
- package/cli/commands/init.js +190 -0
- package/cli/commands/report.js +41 -0
- package/cli/commands/run.js +350 -0
- package/cli/index.js +85 -0
- package/cli/ui.js +126 -0
- package/core/auth.js +148 -0
- package/core/browser.js +1049 -0
- package/core/credentials.js +47 -0
- package/core/db.js +503 -0
- package/core/llm.js +641 -0
- package/core/recorder.js +653 -0
- package/core/reporter.js +282 -0
- package/core/tracker.js +768 -0
- package/package.json +54 -0
- package/web/app/index.html +5937 -0
- package/web/index.html +644 -0
- package/web/invite.html +244 -0
- package/web/login.html +271 -0
- package/web/reset.html +222 -0
- package/web/setup.html +300 -0
package/.dockerignore
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# ===== Skopix .dockerignore =====
|
|
2
|
+
# Keep the Docker build context tiny. Anything not needed at runtime stays out
|
|
3
|
+
# of the image.
|
|
4
|
+
|
|
5
|
+
# Node
|
|
6
|
+
node_modules
|
|
7
|
+
npm-debug.log
|
|
8
|
+
yarn-error.log
|
|
9
|
+
.npm
|
|
10
|
+
.pnpm-store
|
|
11
|
+
|
|
12
|
+
# Git
|
|
13
|
+
.git
|
|
14
|
+
.gitignore
|
|
15
|
+
.github
|
|
16
|
+
|
|
17
|
+
# Editor/IDE
|
|
18
|
+
.vscode
|
|
19
|
+
.idea
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
Thumbs.db
|
|
26
|
+
ehthumbs.db
|
|
27
|
+
Desktop.ini
|
|
28
|
+
|
|
29
|
+
# Build outputs and cache
|
|
30
|
+
dist
|
|
31
|
+
build
|
|
32
|
+
.cache
|
|
33
|
+
.next
|
|
34
|
+
coverage
|
|
35
|
+
.nyc_output
|
|
36
|
+
|
|
37
|
+
# Skopix-specific runtime data - these belong in the mounted volume, not the image
|
|
38
|
+
skopix-reports
|
|
39
|
+
*.suite.yaml
|
|
40
|
+
_saved.suite.yaml
|
|
41
|
+
.skopix.env
|
|
42
|
+
credentials.yaml
|
|
43
|
+
|
|
44
|
+
# Docker files themselves (we don't need them inside the image)
|
|
45
|
+
Dockerfile
|
|
46
|
+
.dockerignore
|
|
47
|
+
docker-compose*.yml
|
|
48
|
+
Caddyfile
|
|
49
|
+
.env
|
|
50
|
+
.env.example
|
|
51
|
+
|
|
52
|
+
# Documentation that's nice in the repo but not needed in the image
|
|
53
|
+
DOCKER.md
|
|
54
|
+
README.md
|
|
55
|
+
LICENSE
|
|
56
|
+
*.md
|
|
57
|
+
|
|
58
|
+
# Test artifacts
|
|
59
|
+
/tmp
|
|
60
|
+
*.test.js.snap
|
|
61
|
+
|
|
62
|
+
# Misc
|
|
63
|
+
.editorconfig
|
|
64
|
+
.prettierrc*
|
|
65
|
+
.eslintrc*
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
name: Build and publish Docker image
|
|
2
|
+
|
|
3
|
+
# When this runs:
|
|
4
|
+
# - On every push to main (publishes 'latest' tag)
|
|
5
|
+
# - On every version tag like v1.2.3 (publishes versioned tags)
|
|
6
|
+
# - Can be triggered manually from the Actions UI
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches:
|
|
10
|
+
- main
|
|
11
|
+
tags:
|
|
12
|
+
- 'v*.*.*'
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
|
|
15
|
+
env:
|
|
16
|
+
REGISTRY: ghcr.io
|
|
17
|
+
IMAGE_NAME: ${{ github.repository }}
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build-and-push:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
packages: write # needed to push to GitHub Container Registry
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- name: Check out code
|
|
29
|
+
uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
# Set up QEMU so we can cross-compile for ARM64 from a regular x86 GitHub runner.
|
|
32
|
+
# This is what makes `--platform linux/arm64` work without needing an ARM runner.
|
|
33
|
+
- name: Set up QEMU
|
|
34
|
+
uses: docker/setup-qemu-action@v3
|
|
35
|
+
|
|
36
|
+
# Buildx is Docker's multi-platform build engine. Without it we can only
|
|
37
|
+
# build for the runner's architecture.
|
|
38
|
+
- name: Set up Docker Buildx
|
|
39
|
+
uses: docker/setup-buildx-action@v3
|
|
40
|
+
|
|
41
|
+
- name: Log in to GitHub Container Registry
|
|
42
|
+
uses: docker/login-action@v3
|
|
43
|
+
with:
|
|
44
|
+
registry: ${{ env.REGISTRY }}
|
|
45
|
+
username: ${{ github.actor }}
|
|
46
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
47
|
+
|
|
48
|
+
# Generate tags + labels automatically based on the trigger.
|
|
49
|
+
# - push to main -> latest
|
|
50
|
+
# - push tag v1.2.3 -> 1.2.3, 1.2, 1, latest
|
|
51
|
+
# - manual run -> sha of commit
|
|
52
|
+
- name: Extract metadata
|
|
53
|
+
id: meta
|
|
54
|
+
uses: docker/metadata-action@v5
|
|
55
|
+
with:
|
|
56
|
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
57
|
+
tags: |
|
|
58
|
+
type=ref,event=branch
|
|
59
|
+
type=semver,pattern={{version}}
|
|
60
|
+
type=semver,pattern={{major}}.{{minor}}
|
|
61
|
+
type=semver,pattern={{major}}
|
|
62
|
+
type=sha
|
|
63
|
+
type=raw,value=latest,enable={{is_default_branch}}
|
|
64
|
+
|
|
65
|
+
- name: Build and push
|
|
66
|
+
uses: docker/build-push-action@v5
|
|
67
|
+
with:
|
|
68
|
+
context: .
|
|
69
|
+
# The two architectures most users care about:
|
|
70
|
+
# - linux/amd64: x86_64 (most cloud VMs, Intel/AMD Macs, Linux desktops)
|
|
71
|
+
# - linux/arm64: ARM64 (Apple Silicon Macs, Raspberry Pi 4/5, AWS Graviton, Hetzner Ampere)
|
|
72
|
+
platforms: linux/amd64,linux/arm64
|
|
73
|
+
push: true
|
|
74
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
75
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
76
|
+
# Cache layers between runs via GitHub Actions cache - speeds up rebuilds significantly
|
|
77
|
+
cache-from: type=gha
|
|
78
|
+
cache-to: type=gha,mode=max
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import readline from 'readline';
|
|
7
|
+
|
|
8
|
+
// ── SKOPIX AGENT ──────────────────────────────────────────────────────────────
|
|
9
|
+
// Runs on a teammate's machine. Connects to the shared Skopix server and
|
|
10
|
+
// executes recording/replay jobs locally (opening a real browser window).
|
|
11
|
+
// The server dispatches jobs to the right person based on their login.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// skopix agent --server http://192.168.1.45:9000 --key "yourteamsecretkey"
|
|
15
|
+
// skopix agent --server https://skopix.yourportix.com --key "yourteamsecretkey"
|
|
16
|
+
|
|
17
|
+
// ── PROMPT HELPER ─────────────────────────────────────────────────────────────
|
|
18
|
+
function prompt(question, hidden = false) {
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
if (hidden) {
|
|
22
|
+
process.stdout.write(question);
|
|
23
|
+
process.stdin.setRawMode(true);
|
|
24
|
+
let input = '';
|
|
25
|
+
process.stdin.on('data', function handler(ch) {
|
|
26
|
+
ch = ch.toString();
|
|
27
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
|
|
28
|
+
process.stdin.setRawMode(false);
|
|
29
|
+
process.stdin.removeListener('data', handler);
|
|
30
|
+
process.stdout.write('\n');
|
|
31
|
+
rl.close();
|
|
32
|
+
resolve(input);
|
|
33
|
+
} else if (ch === '\u0003') {
|
|
34
|
+
process.exit();
|
|
35
|
+
} else if (ch === '\u007f') {
|
|
36
|
+
input = input.slice(0, -1);
|
|
37
|
+
} else {
|
|
38
|
+
input += ch;
|
|
39
|
+
process.stdout.write('*');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
process.stdin.resume();
|
|
43
|
+
} else {
|
|
44
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer); });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── AUTH: identify this agent with your Skopix login ─────────────────────────
|
|
50
|
+
async function promptAndAuth(serverUrl, secretKey) {
|
|
51
|
+
// First check if server is in team mode
|
|
52
|
+
try {
|
|
53
|
+
const checkRes = await fetch(serverUrl + '/api/agent/auth', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json', 'x-skopix-key': secretKey },
|
|
56
|
+
body: JSON.stringify({ email: '', password: '' }),
|
|
57
|
+
});
|
|
58
|
+
// 400 = team mode (bad request), 200 with userId null = solo mode
|
|
59
|
+
if (checkRes.status === 404) return { userId: null, name: 'local' }; // solo mode, endpoint doesn't exist
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
console.log(chalk.cyan(' Enter your Skopix dashboard login to identify this agent:'));
|
|
63
|
+
const email = await prompt(' Email: ');
|
|
64
|
+
const password = await prompt(' Password: ', true);
|
|
65
|
+
|
|
66
|
+
const res = await fetch(serverUrl + '/api/agent/auth', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json', 'x-skopix-key': secretKey },
|
|
69
|
+
body: JSON.stringify({ email: email.trim(), password }),
|
|
70
|
+
});
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
if (!res.ok) throw new Error(data.error || 'Auth failed');
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function agentCommand(options) {
|
|
77
|
+
const serverUrl = (options.server || process.env.SKOPIX_SERVER_URL || '').replace(/\/$/, '');
|
|
78
|
+
const secretKey = options.key || process.env.SKOPIX_SECRET_KEY;
|
|
79
|
+
const agentName = options.name || os.hostname();
|
|
80
|
+
const machine = os.hostname() + ' (' + os.platform() + ')';
|
|
81
|
+
const agentId = crypto.randomUUID();
|
|
82
|
+
|
|
83
|
+
if (!serverUrl) {
|
|
84
|
+
console.error(chalk.red('✖ --server is required. Example: skopix agent --server http://192.168.1.45:9000 --key "secret"'));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
if (!secretKey) {
|
|
88
|
+
console.error(chalk.red('✖ --key is required (same SKOPIX_SECRET_KEY as the server)'));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('');
|
|
93
|
+
console.log(chalk.cyan(' ┌─────────────────────────────────────────────┐'));
|
|
94
|
+
console.log(chalk.cyan(' │') + ' SKOPIX AGENT ' + chalk.cyan('│'));
|
|
95
|
+
console.log(chalk.cyan(' └─────────────────────────────────────────────┘'));
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(' Machine : ' + chalk.white(machine));
|
|
98
|
+
console.log(' Server : ' + chalk.white(serverUrl));
|
|
99
|
+
console.log('');
|
|
100
|
+
|
|
101
|
+
// Authenticate to identify this agent with the correct user
|
|
102
|
+
let userId = null;
|
|
103
|
+
let userName = agentName;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const authData = await promptAndAuth(serverUrl, secretKey);
|
|
107
|
+
userId = authData.userId;
|
|
108
|
+
userName = authData.name || agentName;
|
|
109
|
+
if (userId) {
|
|
110
|
+
console.log(chalk.green(' ✔ Authenticated as ') + chalk.white(userName));
|
|
111
|
+
} else {
|
|
112
|
+
console.log(chalk.cyan(' ◆ Solo mode — no user auth needed'));
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.log(chalk.yellow(' ⚠ Could not authenticate: ' + err.message));
|
|
116
|
+
console.log(chalk.yellow(' ⚠ Connecting anonymously — replays may dispatch to any available agent'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log('');
|
|
120
|
+
|
|
121
|
+
const wsUrl = serverUrl.replace(/^http/, 'ws') + '/agent';
|
|
122
|
+
let reconnectDelay = 2000;
|
|
123
|
+
let running = true;
|
|
124
|
+
let ws = null;
|
|
125
|
+
|
|
126
|
+
process.on('SIGINT', () => {
|
|
127
|
+
running = false;
|
|
128
|
+
if (ws) try { ws.close(); } catch {}
|
|
129
|
+
console.log('\n' + chalk.yellow(' Agent stopped.'));
|
|
130
|
+
process.exit(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
async function connect() {
|
|
134
|
+
console.log(chalk.cyan(' Connecting to server...'));
|
|
135
|
+
|
|
136
|
+
ws = new WebSocket(wsUrl, { headers: { 'x-skopix-key': secretKey } });
|
|
137
|
+
|
|
138
|
+
ws.addEventListener('open', () => {
|
|
139
|
+
reconnectDelay = 2000;
|
|
140
|
+
ws.send(JSON.stringify({ type: 'register', agentId, name: userName, machine, userId }));
|
|
141
|
+
console.log(chalk.green(' ✔ Connected — waiting for jobs\n'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
ws.addEventListener('message', async (event) => {
|
|
145
|
+
let msg;
|
|
146
|
+
try { msg = JSON.parse(event.data); } catch { return; }
|
|
147
|
+
|
|
148
|
+
if (msg.type === 'registered') return;
|
|
149
|
+
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return; }
|
|
150
|
+
if (msg.type === 'record') { await handleRecord(msg); return; }
|
|
151
|
+
if (msg.type === 'replay') { await handleReplay(msg); return; }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
ws.addEventListener('close', () => {
|
|
155
|
+
if (!running) return;
|
|
156
|
+
console.log(chalk.yellow(' ⚠ Disconnected. Reconnecting in ' + (reconnectDelay / 1000) + 's...'));
|
|
157
|
+
setTimeout(connect, reconnectDelay);
|
|
158
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
ws.addEventListener('error', (err) => {
|
|
162
|
+
console.error(chalk.red(' ✖ ' + (err.message || 'Connection error')));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── RECORD JOB ─────────────────────────────────────────────────────────────
|
|
167
|
+
async function handleRecord(msg) {
|
|
168
|
+
const { recordingId, url } = msg;
|
|
169
|
+
console.log(chalk.cyan(' ⏺ Recording: ') + chalk.white(url));
|
|
170
|
+
|
|
171
|
+
const send = (data) => {
|
|
172
|
+
try { ws.send(JSON.stringify({ type: 'recordingUpdate', recordingId, data })); } catch {}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const screenshotDir = path.join(os.homedir(), '.skopix', 'recordings', recordingId);
|
|
176
|
+
await fs.ensureDir(screenshotDir);
|
|
177
|
+
|
|
178
|
+
const recorderPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'core', 'recorder.js');
|
|
179
|
+
const { spawn } = await import('child_process');
|
|
180
|
+
const child = spawn('node', [recorderPath, url, recordingId, screenshotDir], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
181
|
+
|
|
182
|
+
child.stdout.on('data', (chunk) => {
|
|
183
|
+
chunk.toString().split('\n').filter(Boolean).forEach(line => {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(line);
|
|
186
|
+
send(parsed);
|
|
187
|
+
if (parsed.type === 'step') process.stdout.write(chalk.cyan(' ⏺ ') + (parsed.step?.action || '') + '\n');
|
|
188
|
+
if (parsed.type === 'done') console.log(chalk.green(' ✔ Recording done — ') + (parsed.steps?.length || 0) + ' steps');
|
|
189
|
+
} catch {}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.stderr.on('data', (chunk) => {
|
|
194
|
+
send({ type: 'error', message: chunk.toString().trim().slice(0, 200) });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
child.on('close', () => { send({ type: 'stopped' }); });
|
|
198
|
+
|
|
199
|
+
// Listen for stop signal from server
|
|
200
|
+
const stopHandler = (event) => {
|
|
201
|
+
let m; try { m = JSON.parse(event.data); } catch { return; }
|
|
202
|
+
if (m.type === 'stopRecord' && m.recordingId === recordingId) {
|
|
203
|
+
try { child.stdin.write('stop\n'); } catch {}
|
|
204
|
+
ws.removeEventListener('message', stopHandler);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
ws.addEventListener('message', stopHandler);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── REPLAY JOB ─────────────────────────────────────────────────────────────
|
|
211
|
+
async function handleReplay(msg) {
|
|
212
|
+
const { runId, test, setupTest, env } = msg;
|
|
213
|
+
console.log(chalk.cyan(' ▶ Replay: ') + chalk.white(test.name));
|
|
214
|
+
|
|
215
|
+
const send = (data) => {
|
|
216
|
+
try { ws.send(JSON.stringify({ type: 'jobUpdate', runId, data })); } catch {}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (env) Object.assign(process.env, env);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const { chromium } = await import('playwright');
|
|
223
|
+
const sessionDir = path.join(os.homedir(), '.skopix', 'sessions', runId);
|
|
224
|
+
await fs.ensureDir(sessionDir);
|
|
225
|
+
|
|
226
|
+
send({ type: 'stdout', text: '' });
|
|
227
|
+
send({ type: 'stdout', text: ' Agent: ' + machine });
|
|
228
|
+
send({ type: 'sessionId', sessionId: runId });
|
|
229
|
+
|
|
230
|
+
const browser = await chromium.launch({ headless: test.headless || false, args: ['--no-sandbox'] });
|
|
231
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, recordVideo: { dir: sessionDir, size: { width: 1280, height: 800 } } });
|
|
232
|
+
const page = await ctx.newPage();
|
|
233
|
+
|
|
234
|
+
const allSteps = [...(setupTest ? (setupTest.steps || []) : []), ...(test.steps || [])];
|
|
235
|
+
let stepNum = 0, passed = true, failReason = '';
|
|
236
|
+
|
|
237
|
+
if (test.url) {
|
|
238
|
+
await page.goto(test.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
239
|
+
await page.waitForTimeout(800);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
send({ type: 'stdout', text: '◆ Replaying ' + allSteps.length + ' steps on ' + os.hostname() });
|
|
243
|
+
|
|
244
|
+
for (const step of allSteps) {
|
|
245
|
+
stepNum++;
|
|
246
|
+
const sel = sanitiseSelector(step.stableSelector || step.selector);
|
|
247
|
+
const isSetup = setupTest && stepNum <= (setupTest.steps || []).length;
|
|
248
|
+
const desc = step.description || (step.action + ' ' + (sel || ''));
|
|
249
|
+
send({ type: 'stdout', text: ' [' + stepNum + '/' + allSteps.length + '] ' + step.action.toUpperCase() + (isSetup ? ' [SETUP]' : '') + ' — ' + desc });
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await executeStep(step, sel, page, test);
|
|
253
|
+
send({ type: 'stdout', text: ' ✓ Done' });
|
|
254
|
+
const screenshotPath = path.join(sessionDir, 'step-' + String(stepNum).padStart(3, '0') + '.png');
|
|
255
|
+
await page.screenshot({ path: screenshotPath }).catch(() => {});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
send({ type: 'stdout', text: ' ✖ FAILED: ' + err.message });
|
|
258
|
+
failReason = err.message;
|
|
259
|
+
passed = false;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const videoPath = await page.video()?.path();
|
|
266
|
+
await ctx.close();
|
|
267
|
+
if (videoPath) await fs.move(videoPath, path.join(sessionDir, 'replay.webm'), { overwrite: true }).catch(() => {});
|
|
268
|
+
} catch { try { await ctx.close(); } catch {} }
|
|
269
|
+
await browser.close();
|
|
270
|
+
|
|
271
|
+
await fs.writeJson(path.join(sessionDir, 'report.json'), {
|
|
272
|
+
sessionId: runId, goalAchieved: passed, url: test.url || '',
|
|
273
|
+
goal: test.name + ' (recorded replay)', steps: allSteps.slice(0, stepNum),
|
|
274
|
+
duration: 0, type: 'replay', provider: 'replay',
|
|
275
|
+
}, { spaces: 2 }).catch(() => {});
|
|
276
|
+
|
|
277
|
+
send({ type: 'stdout', text: '' });
|
|
278
|
+
send({ type: 'stdout', text: '━'.repeat(60) });
|
|
279
|
+
send({ type: 'stdout', text: ' Status: ' + (passed ? 'PASSED ✓' : 'FAILED ✗') });
|
|
280
|
+
if (!passed) send({ type: 'stdout', text: ' Reason: ' + failReason });
|
|
281
|
+
send({ type: 'done', exitCode: passed ? 0 : 1, status: passed ? 'passed' : 'failed' });
|
|
282
|
+
|
|
283
|
+
console.log((passed ? chalk.green(' ✔ PASSED') : chalk.red(' ✖ FAILED')) + ' — ' + test.name);
|
|
284
|
+
console.log(chalk.cyan(' ◆ Waiting for jobs\n'));
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error(chalk.red(' ✖ Replay error: ' + err.message));
|
|
287
|
+
send({ type: 'stdout', text: '✖ Agent error: ' + err.message });
|
|
288
|
+
send({ type: 'done', exitCode: 1, status: 'failed' });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── HELPERS ────────────────────────────────────────────────────────────────
|
|
293
|
+
function sanitiseSelector(sel) {
|
|
294
|
+
if (!sel) return sel;
|
|
295
|
+
return sel.replace(/\[([a-zA-Z_-]+)="([^"]+\.\d{5,})"\]/g, (_, attr, val) => '[' + attr + '*="' + val.replace(/\.\d{5,}$/, '') + '"]');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function executeStep(step, sel, page, test) {
|
|
299
|
+
if (step.action === 'navigate') {
|
|
300
|
+
let navUrl = step.url || step.value;
|
|
301
|
+
if (test.url && navUrl) {
|
|
302
|
+
try { const ro = new URL(navUrl).origin; const to = new URL(test.url).origin; if (ro !== to) navUrl = navUrl.replace(ro, to); } catch {}
|
|
303
|
+
}
|
|
304
|
+
await page.goto(navUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
305
|
+
await page.waitForTimeout(800);
|
|
306
|
+
|
|
307
|
+
} else if (step.action === 'click') {
|
|
308
|
+
await page.waitForTimeout(200);
|
|
309
|
+
let clicked = false;
|
|
310
|
+
const selectors = [step.stableSelector, step.selector].filter(Boolean).map(sanitiseSelector);
|
|
311
|
+
if (!clicked && (step.elementX || step.clickX)) {
|
|
312
|
+
const tx = step.elementX || step.clickX, ty = step.elementY || step.clickY;
|
|
313
|
+
for (const s of selectors) {
|
|
314
|
+
if (clicked) break;
|
|
315
|
+
try {
|
|
316
|
+
const count = await page.locator(s).count();
|
|
317
|
+
if (count > 1) {
|
|
318
|
+
let bi = 0, bd = Infinity;
|
|
319
|
+
for (let i = 0; i < count; i++) { try { const box = await page.locator(s).nth(i).boundingBox({ timeout: 2000 }); if (!box) continue; const d = Math.sqrt(Math.pow(box.x + box.width / 2 - tx, 2) + Math.pow(box.y + box.height / 2 - ty, 2)); if (d < bd) { bd = d; bi = i; } } catch {} }
|
|
320
|
+
await page.locator(s).nth(bi).click({ timeout: 5000 }); clicked = true;
|
|
321
|
+
} else if (count === 1) { await page.locator(s).first().click({ timeout: 5000 }); clicked = true; }
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ timeout: 5000 }); clicked = true; } catch {} } }
|
|
326
|
+
if (!clicked) { for (const s of selectors) { if (clicked) break; try { await page.locator(s).first().click({ force: true, timeout: 5000 }); clicked = true; } catch {} } }
|
|
327
|
+
if (!clicked) throw new Error('Could not click: ' + selectors.join(', '));
|
|
328
|
+
await page.waitForTimeout(400);
|
|
329
|
+
|
|
330
|
+
} else if (step.action === 'type') {
|
|
331
|
+
await page.locator(sel).first().click({ timeout: 5000 });
|
|
332
|
+
await page.locator(sel).first().fill('');
|
|
333
|
+
await page.locator(sel).first().pressSequentially(step.value || '', { delay: 50 });
|
|
334
|
+
await page.locator(sel).first().evaluate(el => {
|
|
335
|
+
el.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
336
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
337
|
+
el.dispatchEvent(new Event('focusout', { bubbles: true }));
|
|
338
|
+
});
|
|
339
|
+
await page.waitForTimeout(300);
|
|
340
|
+
|
|
341
|
+
} else if (step.action === 'check') {
|
|
342
|
+
// Use click() rather than check() — Playwright's check() sets the property
|
|
343
|
+
// but doesn't always dispatch the real click event Angular needs.
|
|
344
|
+
// First verify if it's already in the desired state to avoid double-toggling.
|
|
345
|
+
let alreadyCorrect = false;
|
|
346
|
+
try {
|
|
347
|
+
const isCurrentlyChecked = await page.locator(sel).first().isChecked({ timeout: 2000 });
|
|
348
|
+
if (isCurrentlyChecked === step.checked) alreadyCorrect = true;
|
|
349
|
+
} catch {}
|
|
350
|
+
if (!alreadyCorrect) {
|
|
351
|
+
await page.locator(sel).first().click({ timeout: 10000 });
|
|
352
|
+
await page.waitForTimeout(400);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
} else if (step.action === 'select') {
|
|
356
|
+
await page.locator(sel).first().selectOption(step.value || '', { timeout: 10000 });
|
|
357
|
+
|
|
358
|
+
} else if (step.action === 'scroll') {
|
|
359
|
+
if (step.isWindow || step.selector === 'window') await page.evaluate(({ x, y }) => window.scrollTo({ left: x, top: y, behavior: 'smooth' }), { x: step.scrollX || 0, y: step.scrollY || 0 });
|
|
360
|
+
else await page.evaluate(({ s, x, y }) => { const el = document.querySelector(s); if (el) el.scrollTo({ left: x, top: y, behavior: 'smooth' }); }, { s: sel, x: step.scrollX || 0, y: step.scrollY || 0 });
|
|
361
|
+
await page.waitForTimeout(500);
|
|
362
|
+
|
|
363
|
+
} else if (step.action === 'assert') {
|
|
364
|
+
const assertSel = sanitiseSelector(step.stableSelector || step.selector);
|
|
365
|
+
switch (step.assertType) {
|
|
366
|
+
case 'visible': await page.locator(assertSel).first().waitFor({ state: 'visible', timeout: 10000 }); break;
|
|
367
|
+
case 'text_contains': { const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 }); if (!txt || !txt.includes(step.value || '')) throw new Error('Expected to contain "' + step.value + '"'); break; }
|
|
368
|
+
case 'text_equals': { const txt = await page.locator(assertSel).first().textContent({ timeout: 10000 }); if ((txt || '').trim() !== (step.value || '').trim()) throw new Error('Expected "' + step.value + '"'); break; }
|
|
369
|
+
case 'url_contains': if (!page.url().includes(step.value || '')) throw new Error('URL does not contain "' + step.value + '"'); break;
|
|
370
|
+
case 'element_count': { const count = await page.locator(assertSel).count(); if (count !== parseInt(step.value || '0', 10)) throw new Error('Expected ' + step.value + ' elements, got ' + count); break; }
|
|
371
|
+
case 'attribute_contains': { const attrName = step.attribute || 'title'; const attrVal = await page.locator(assertSel).first().getAttribute(attrName, { timeout: 10000 }); if (!attrVal || !attrVal.includes(step.value || '')) throw new Error(attrName + ' does not contain "' + step.value + '"'); break; }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Start connecting
|
|
377
|
+
await connect();
|
|
378
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
|
|
6
|
+
const ENV_FILE = '.skopix.env';
|
|
7
|
+
|
|
8
|
+
export async function configCommand(options) {
|
|
9
|
+
const envPath = path.resolve(process.cwd(), ENV_FILE);
|
|
10
|
+
|
|
11
|
+
if (options.list) {
|
|
12
|
+
if (!await fs.pathExists(envPath)) {
|
|
13
|
+
console.log(chalk.yellow('\n No config found. Run `skopix init` first.\n'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const config = dotenv.parse(await fs.readFile(envPath));
|
|
17
|
+
console.log(chalk.cyan.bold('\n Skopix Configuration\n'));
|
|
18
|
+
for (const [key, value] of Object.entries(config)) {
|
|
19
|
+
const masked = key.includes('KEY') || key.includes('TOKEN') || key.includes('PASSWORD')
|
|
20
|
+
? value.slice(0, 4) + '****'
|
|
21
|
+
: value;
|
|
22
|
+
console.log(` ${chalk.white(key)} = ${chalk.yellow(masked)}`);
|
|
23
|
+
}
|
|
24
|
+
console.log();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.set) {
|
|
29
|
+
const [key, ...rest] = options.set.split('=');
|
|
30
|
+
const value = rest.join('=');
|
|
31
|
+
if (!key || !value) {
|
|
32
|
+
console.log(chalk.red('\n Usage: skopix config --set KEY=value\n'));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let existing = {};
|
|
37
|
+
if (await fs.pathExists(envPath)) {
|
|
38
|
+
existing = dotenv.parse(await fs.readFile(envPath));
|
|
39
|
+
}
|
|
40
|
+
existing[key.trim()] = value.trim();
|
|
41
|
+
|
|
42
|
+
const lines = ['# Skopix Configuration', ''];
|
|
43
|
+
for (const [k, v] of Object.entries(existing)) {
|
|
44
|
+
lines.push(`${k}=${v}`);
|
|
45
|
+
}
|
|
46
|
+
await fs.writeFile(envPath, lines.join('\n') + '\n');
|
|
47
|
+
console.log(chalk.green(`\n ✓ Set ${key} in ${ENV_FILE}\n`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.get) {
|
|
52
|
+
if (!await fs.pathExists(envPath)) {
|
|
53
|
+
console.log(chalk.yellow('\n No config found.\n'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const config = dotenv.parse(await fs.readFile(envPath));
|
|
57
|
+
const value = config[options.get];
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
console.log(chalk.yellow(`\n Key not found: ${options.get}\n`));
|
|
60
|
+
} else {
|
|
61
|
+
console.log(chalk.green(`\n ${options.get} = ${value}\n`));
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(chalk.dim('\n Use --list, --set, or --get. See skopix config --help\n'));
|
|
67
|
+
}
|