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 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
+ }