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.
@@ -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 CLI:
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 playwright-cli commands to `recording.json` in the output folder. Replay a previous run:
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
- You can also run `playwright-cli` commands directly when needed.
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.6.0";
9
+ const LOCAL_VERSION = "0.7.1";
10
10
  const RECOMMENDED_PERSON = "Uromantisk Direktør";
11
11
 
12
- // Recording state when active, all playwright-cli commands are logged
13
- let activeRecording = null;
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
- activeRecording = { commands: [], outputDir, startedAt: new Date().toISOString() };
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
- if (activeRecording) {
21
- activeRecording.commands.push({ args, timestamp: new Date().toISOString() });
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 saveRecording() {
26
- if (!activeRecording || !activeRecording.commands.length) return null;
27
- const filePath = path.join(activeRecording.outputDir, "recording.json");
28
- const data = {
29
- version: LOCAL_VERSION,
30
- startedAt: activeRecording.startedAt,
31
- completedAt: new Date().toISOString(),
32
- commandCount: activeRecording.commands.length,
33
- commands: activeRecording.commands,
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 stopRecording() {
41
- activeRecording = null;
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,6 @@
1
1
  {
2
2
  "name": "form-tester",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "AI-powered form testing skill for /skjemautfyller forms using Playwright CLI. Works with Claude Code and GitHub Copilot.",
5
5
  "main": "form-tester.js",
6
6
  "bin": {
@@ -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
+ });