form-tester 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/form-tester/SKILL.md +23 -3
- package/form-tester.js +116 -25
- package/package.json +1 -1
- package/tests/form-tester.test.js +62 -0
|
@@ -20,7 +20,10 @@ form-tester install --global # or install to ~/.claude/skills/
|
|
|
20
20
|
form-tester test <url> --auto
|
|
21
21
|
form-tester test <url> --auto --pnr 12345 --persona ung-mann --scenario "test validation"
|
|
22
22
|
|
|
23
|
-
# Interactive
|
|
23
|
+
# Interactive mode (prompts for persona, scenario, etc.):
|
|
24
|
+
form-tester test <url> --human
|
|
25
|
+
|
|
26
|
+
# Full interactive CLI:
|
|
24
27
|
form-tester
|
|
25
28
|
```
|
|
26
29
|
|
|
@@ -28,8 +31,17 @@ Persona IDs: `ung-mann`, `gravid-kvinne`, `eldre-kvinne`, `kronisk-syk-mann`. De
|
|
|
28
31
|
|
|
29
32
|
## Recording & Replay
|
|
30
33
|
|
|
31
|
-
Every test run records all
|
|
34
|
+
Every test run records all commands to `recording.json` in the output folder. To ensure ALL commands are recorded (including form filling, clicking, etc.), always use `form-tester exec` instead of `playwright-cli` directly:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# ALWAYS use this instead of playwright-cli directly:
|
|
38
|
+
form-tester exec fill e1 "value"
|
|
39
|
+
form-tester exec click e3
|
|
40
|
+
form-tester exec screenshot --filename "path.png" --full-page
|
|
41
|
+
form-tester exec close # finalizes and saves the recording
|
|
42
|
+
```
|
|
32
43
|
|
|
44
|
+
Replay a previous run:
|
|
33
45
|
```bash
|
|
34
46
|
form-tester replay output/form-id/timestamp/recording.json
|
|
35
47
|
```
|
|
@@ -57,4 +69,12 @@ Use `/people` to rescan the visible person list and get a numbered selection pro
|
|
|
57
69
|
|
|
58
70
|
## Playwright CLI
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
IMPORTANT: Always use `form-tester exec` instead of `playwright-cli` directly. This ensures all commands are recorded for replay. The syntax is the same — just prefix with `form-tester exec`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
form-tester exec snapshot
|
|
76
|
+
form-tester exec fill e1 "value"
|
|
77
|
+
form-tester exec click e3
|
|
78
|
+
form-tester exec screenshot --filename "page.png" --full-page
|
|
79
|
+
form-tester exec close
|
|
80
|
+
```
|
package/form-tester.js
CHANGED
|
@@ -6,39 +6,78 @@ const { spawn, execSync } = require("child_process");
|
|
|
6
6
|
|
|
7
7
|
const CONFIG_PATH = path.join(process.cwd(), "form-tester.config.json");
|
|
8
8
|
const OUTPUT_BASE = path.resolve(process.cwd(), "output");
|
|
9
|
-
const LOCAL_VERSION = "0.
|
|
9
|
+
const LOCAL_VERSION = "0.7.1";
|
|
10
10
|
const RECOMMENDED_PERSON = "Uromantisk Direktør";
|
|
11
11
|
|
|
12
|
-
// Recording
|
|
13
|
-
let
|
|
12
|
+
// Recording — persisted to disk so `form-tester exec` can append across processes
|
|
13
|
+
let activeRecordingPath = null;
|
|
14
14
|
|
|
15
15
|
function startRecording(outputDir) {
|
|
16
|
-
|
|
16
|
+
const filePath = path.join(outputDir, "recording.json");
|
|
17
|
+
const data = {
|
|
18
|
+
version: LOCAL_VERSION,
|
|
19
|
+
startedAt: new Date().toISOString(),
|
|
20
|
+
completedAt: null,
|
|
21
|
+
commandCount: 0,
|
|
22
|
+
commands: [],
|
|
23
|
+
};
|
|
24
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
25
|
+
activeRecordingPath = filePath;
|
|
26
|
+
return filePath;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
function recordCommand(args) {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
// In-process recording
|
|
31
|
+
if (activeRecordingPath && fs.existsSync(activeRecordingPath)) {
|
|
32
|
+
appendToRecording(activeRecordingPath, args);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Check config for active recording (cross-process via exec)
|
|
36
|
+
try {
|
|
37
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
38
|
+
if (config.activeRecording && fs.existsSync(config.activeRecording)) {
|
|
39
|
+
appendToRecording(config.activeRecording, args);
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// no config or no active recording, skip
|
|
22
43
|
}
|
|
23
44
|
}
|
|
24
45
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
35
|
-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
36
|
-
activeRecording = null;
|
|
37
|
-
return filePath;
|
|
46
|
+
function appendToRecording(filePath, args) {
|
|
47
|
+
try {
|
|
48
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
49
|
+
data.commands.push({ args, timestamp: new Date().toISOString() });
|
|
50
|
+
data.commandCount = data.commands.length;
|
|
51
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// recording file corrupted or gone, skip
|
|
54
|
+
}
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
function
|
|
41
|
-
|
|
57
|
+
function finalizeRecording(filePath) {
|
|
58
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
59
|
+
try {
|
|
60
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
61
|
+
data.completedAt = new Date().toISOString();
|
|
62
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
63
|
+
return filePath;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function saveRecording() {
|
|
70
|
+
const result = finalizeRecording(activeRecordingPath);
|
|
71
|
+
activeRecordingPath = null;
|
|
72
|
+
// Clear active recording from config
|
|
73
|
+
try {
|
|
74
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
75
|
+
delete config.activeRecording;
|
|
76
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// config not found, skip
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
const PERSONAS = [
|
|
@@ -854,8 +893,10 @@ async function handleTest(url, config) {
|
|
|
854
893
|
config.lastRunDir = outputDir;
|
|
855
894
|
saveConfig(config);
|
|
856
895
|
|
|
857
|
-
// Start recording
|
|
858
|
-
startRecording(outputDir);
|
|
896
|
+
// Start recording and persist path to config for `exec`
|
|
897
|
+
const recordingFile = startRecording(outputDir);
|
|
898
|
+
config.activeRecording = recordingFile;
|
|
899
|
+
saveConfig(config);
|
|
859
900
|
|
|
860
901
|
if (personaChoice.type === "preset") {
|
|
861
902
|
fs.writeFileSync(
|
|
@@ -971,8 +1012,10 @@ async function handleTestAuto(url, config, flags) {
|
|
|
971
1012
|
config.lastRunDir = outputDir;
|
|
972
1013
|
saveConfig(config);
|
|
973
1014
|
|
|
974
|
-
// Start recording
|
|
975
|
-
startRecording(outputDir);
|
|
1015
|
+
// Start recording and persist path to config for `exec`
|
|
1016
|
+
const recordingFile = startRecording(outputDir);
|
|
1017
|
+
config.activeRecording = recordingFile;
|
|
1018
|
+
saveConfig(config);
|
|
976
1019
|
|
|
977
1020
|
// Save persona and scenario
|
|
978
1021
|
if (personaChoice.type === "preset") {
|
|
@@ -1213,6 +1256,40 @@ async function main() {
|
|
|
1213
1256
|
process.exit(0);
|
|
1214
1257
|
}
|
|
1215
1258
|
|
|
1259
|
+
if (args[0] === "exec") {
|
|
1260
|
+
const pwArgs = args.slice(1);
|
|
1261
|
+
if (!pwArgs.length) {
|
|
1262
|
+
console.error("Usage: form-tester exec <playwright-cli command and args>");
|
|
1263
|
+
process.exit(1);
|
|
1264
|
+
}
|
|
1265
|
+
// Record (reads config.activeRecording) and run playwright-cli directly
|
|
1266
|
+
recordCommand(pwArgs);
|
|
1267
|
+
// Run without going through runPlaywrightCli to avoid double-recording
|
|
1268
|
+
const spec = getPlaywrightCommandSpec();
|
|
1269
|
+
const spawnOpts = { stdio: "inherit" };
|
|
1270
|
+
if (spec.shell) spawnOpts.shell = true;
|
|
1271
|
+
const code = await new Promise((resolve) => {
|
|
1272
|
+
const child = spawn(spec.command, [...spec.args, ...pwArgs], spawnOpts);
|
|
1273
|
+
child.on("error", (err) => { console.error(err.message); resolve(1); });
|
|
1274
|
+
child.on("exit", (c) => resolve(c ?? 0));
|
|
1275
|
+
});
|
|
1276
|
+
// If the command was "close", finalize the recording
|
|
1277
|
+
if (pwArgs[0] === "close") {
|
|
1278
|
+
try {
|
|
1279
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
1280
|
+
if (config.activeRecording) {
|
|
1281
|
+
const rPath = finalizeRecording(config.activeRecording);
|
|
1282
|
+
delete config.activeRecording;
|
|
1283
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1284
|
+
if (rPath) console.log(`Recording saved: ${rPath}`);
|
|
1285
|
+
}
|
|
1286
|
+
} catch (e) {
|
|
1287
|
+
// no config, skip
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
process.exit(code);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1216
1293
|
if (args[0] === "replay") {
|
|
1217
1294
|
const filePath = args[1];
|
|
1218
1295
|
if (!filePath) {
|
|
@@ -1223,6 +1300,17 @@ async function main() {
|
|
|
1223
1300
|
process.exit(0);
|
|
1224
1301
|
}
|
|
1225
1302
|
|
|
1303
|
+
if (args[0] === "test" && args.includes("--human")) {
|
|
1304
|
+
const config = loadConfig();
|
|
1305
|
+
const url = args.find((a) => a.startsWith("http"));
|
|
1306
|
+
if (!url) {
|
|
1307
|
+
console.error("Usage: form-tester test <url> --human");
|
|
1308
|
+
process.exit(1);
|
|
1309
|
+
}
|
|
1310
|
+
await handleTest(url, config);
|
|
1311
|
+
process.exit(0);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1226
1314
|
if (args[0] === "test" && args.includes("--auto")) {
|
|
1227
1315
|
const config = loadConfig();
|
|
1228
1316
|
const url = args.find((a) => a.startsWith("http"));
|
|
@@ -1285,5 +1373,8 @@ module.exports = {
|
|
|
1285
1373
|
getPersonaById,
|
|
1286
1374
|
formatPersonaList,
|
|
1287
1375
|
promptScenario,
|
|
1376
|
+
startRecording,
|
|
1377
|
+
appendToRecording,
|
|
1378
|
+
finalizeRecording,
|
|
1288
1379
|
};
|
|
1289
1380
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const test = require("node:test");
|
|
2
2
|
const assert = require("node:assert/strict");
|
|
3
3
|
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
|
|
4
8
|
const {
|
|
5
9
|
extractFormId,
|
|
6
10
|
extractPnrFromUrl,
|
|
@@ -14,6 +18,9 @@ const {
|
|
|
14
18
|
getPersonaById,
|
|
15
19
|
formatPersonaList,
|
|
16
20
|
promptScenario,
|
|
21
|
+
startRecording,
|
|
22
|
+
appendToRecording,
|
|
23
|
+
finalizeRecording,
|
|
17
24
|
} = require("../form-tester");
|
|
18
25
|
|
|
19
26
|
test("extractFormId returns form id from skjemautfyller URL", () => {
|
|
@@ -118,3 +125,58 @@ test("each persona has a unique id", () => {
|
|
|
118
125
|
test("promptScenario is exported as a function", () => {
|
|
119
126
|
assert.equal(typeof promptScenario, "function");
|
|
120
127
|
});
|
|
128
|
+
|
|
129
|
+
// --- Recording tests ---
|
|
130
|
+
|
|
131
|
+
function makeTmpDir() {
|
|
132
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "form-tester-test-"));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("startRecording creates recording.json with empty commands", () => {
|
|
136
|
+
const dir = makeTmpDir();
|
|
137
|
+
const filePath = startRecording(dir);
|
|
138
|
+
assert.equal(filePath, path.join(dir, "recording.json"));
|
|
139
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
140
|
+
assert.equal(data.commandCount, 0);
|
|
141
|
+
assert.deepEqual(data.commands, []);
|
|
142
|
+
assert.ok(data.startedAt);
|
|
143
|
+
assert.equal(data.completedAt, null);
|
|
144
|
+
fs.rmSync(dir, { recursive: true });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("appendToRecording adds commands to recording file", () => {
|
|
148
|
+
const dir = makeTmpDir();
|
|
149
|
+
const filePath = startRecording(dir);
|
|
150
|
+
appendToRecording(filePath, ["open", "https://example.com"]);
|
|
151
|
+
appendToRecording(filePath, ["fill", "e1", "hello"]);
|
|
152
|
+
appendToRecording(filePath, ["click", "e3"]);
|
|
153
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
154
|
+
assert.equal(data.commandCount, 3);
|
|
155
|
+
assert.deepEqual(data.commands[0].args, ["open", "https://example.com"]);
|
|
156
|
+
assert.deepEqual(data.commands[1].args, ["fill", "e1", "hello"]);
|
|
157
|
+
assert.deepEqual(data.commands[2].args, ["click", "e3"]);
|
|
158
|
+
assert.ok(data.commands[0].timestamp);
|
|
159
|
+
fs.rmSync(dir, { recursive: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("finalizeRecording sets completedAt timestamp", () => {
|
|
163
|
+
const dir = makeTmpDir();
|
|
164
|
+
const filePath = startRecording(dir);
|
|
165
|
+
appendToRecording(filePath, ["snapshot"]);
|
|
166
|
+
const result = finalizeRecording(filePath);
|
|
167
|
+
assert.equal(result, filePath);
|
|
168
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
169
|
+
assert.ok(data.completedAt);
|
|
170
|
+
assert.equal(data.commandCount, 1);
|
|
171
|
+
fs.rmSync(dir, { recursive: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("finalizeRecording returns null for missing file", () => {
|
|
175
|
+
assert.equal(finalizeRecording("/nonexistent/recording.json"), null);
|
|
176
|
+
assert.equal(finalizeRecording(null), null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("appendToRecording is resilient to missing file", () => {
|
|
180
|
+
// Should not throw
|
|
181
|
+
appendToRecording("/nonexistent/recording.json", ["click", "e1"]);
|
|
182
|
+
});
|