@asrunjs/asrun 0.10.0 → 0.11.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/.claude/skills/exploring-cases/SKILL.md +45 -58
- package/.claude/skills/exploring-cases/references/notes.md +87 -0
- package/.claude/skills/exploring-cases/references/readiness.md +3 -2
- package/.claude/skills/exploring-cases/references/tools.md +34 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +1 -1
- package/.claude/skills/generating-specs/SKILL.md +44 -37
- package/AGENTS.md +10 -9
- package/README.md +6 -5
- package/bin/asrun.mjs +1 -0
- package/dist/lib/explore-notes.d.ts +74 -0
- package/dist/lib/explore-notes.js +242 -0
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +2 -0
- package/dist/scripts/cli.js +9 -2
- package/dist/scripts/explore-notes.d.ts +1 -0
- package/dist/scripts/explore-notes.js +131 -0
- package/docs/agents/case-format.md +1 -0
- package/docs/agents/framework-dev.md +4 -0
- package/docs/ai-workflow.md +9 -1
- package/docs/cases.md +4 -4
- package/docs/commands.md +11 -0
- package/docs/theme-guide.html +56 -0
- package/docs/web-ui.md +30 -9
- package/package.json +2 -1
- package/templates/package.json +1 -0
- package/ui/dist/assets/{dist-BmWaNjqO.js → dist--BXJInTq.js} +1 -1
- package/ui/dist/assets/{dist-2msWI-5M.js → dist-BMmNFIUH.js} +1 -1
- package/ui/dist/assets/{dist-sfZKBSMm.js → dist-BXNaYHeh.js} +1 -1
- package/ui/dist/assets/{dist-BVvHVJ3N.js → dist-ByLbEOlF.js} +1 -1
- package/ui/dist/assets/{dist-e-KhTWHJ.js → dist-C9ra6_BD.js} +1 -1
- package/ui/dist/assets/{dist-4U_gPHXf.js → dist-CK8mow8u.js} +1 -1
- package/ui/dist/assets/{dist-BwNz_xW2.js → dist-CQS0So-i.js} +1 -1
- package/ui/dist/assets/{dist-J36xayOv.js → dist-CTrwRc3Q.js} +1 -1
- package/ui/dist/assets/{dist-BMfJatHo.js → dist-DTYj2vUD.js} +1 -1
- package/ui/dist/assets/{dist-wjB7g6OJ.js → dist-mV0_nQZe.js} +1 -1
- package/ui/dist/assets/{index-CGbRJT3b.js → index-BzMqkmQ9.js} +41 -41
- package/ui/dist/assets/index-DcJR-8Mt.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CI0i0LWc.css +0 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isValidCaseId } from './case-id.js';
|
|
5
|
+
import { CASES_DIR, EXPLORE_DIR } from './paths.js';
|
|
6
|
+
/**
|
|
7
|
+
* The local exploration note: what `/explore-case` saw on the page, in a form `/gen-spec` can read.
|
|
8
|
+
*
|
|
9
|
+
* It lives under `.explore/notes/<case-id>/<profile-id>.json`, one file per profile, so a run with `--profiles` never
|
|
10
|
+
* has to merge with an earlier one. `.explore/` is git-ignored local state: a note is optional, may be deleted at any
|
|
11
|
+
* time, and a fresh checkout simply has none (generation then replays the case as before).
|
|
12
|
+
*
|
|
13
|
+
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
14
|
+
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
15
|
+
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
16
|
+
*/
|
|
17
|
+
export const NOTES_VERSION = 1;
|
|
18
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
19
|
+
const PROFILE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
20
|
+
const STEP_KEY = /^[1-9]\d*$/;
|
|
21
|
+
const TARGET_KEY = /^T[1-9]\d*$/;
|
|
22
|
+
// A request pattern is a path, never a URL with a host or a query string that could carry a token.
|
|
23
|
+
const UNSAFE_REQUEST = /[?#]|:\/\//;
|
|
24
|
+
const SECRET_PARAM = /(?:^|[/&])(?:token|cookie|password|authorization|secret|signature|sig|session)=/i;
|
|
25
|
+
const MAX_LOCATOR = 400;
|
|
26
|
+
const MAX_PURPOSE = 200;
|
|
27
|
+
const MAX_REQUEST = 200;
|
|
28
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
const isOneLine = (value) => !/[\r\n]/.test(value);
|
|
30
|
+
/** The folder that holds one case's notes; `<case-id>` may contain its group folder (`documents/landing-page`). */
|
|
31
|
+
export function exploreNotesDir(caseId) {
|
|
32
|
+
if (!isValidCaseId(caseId))
|
|
33
|
+
throw new Error(`"${caseId}" is not a valid case id`);
|
|
34
|
+
return path.join(EXPLORE_DIR, 'notes', ...caseId.split('/'));
|
|
35
|
+
}
|
|
36
|
+
/** The note of one profile: `.explore/notes/<case-id>/<profile-id>.json`. */
|
|
37
|
+
export function exploreNotesFile(caseId, profileId) {
|
|
38
|
+
if (!PROFILE_FILE.test(profileId))
|
|
39
|
+
throw new Error(`"${profileId}" is not a valid profile id`);
|
|
40
|
+
return path.join(exploreNotesDir(caseId), `${profileId}.json`);
|
|
41
|
+
}
|
|
42
|
+
/** sha256 of the case document, or null when it does not exist. */
|
|
43
|
+
export function caseSha256(caseId) {
|
|
44
|
+
if (!isValidCaseId(caseId))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return crypto.createHash('sha256').update(fs.readFileSync(path.join(CASES_DIR, `${caseId}.md`))).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a note still describes the case. A stale note is not an error: `/gen-spec` replays the case and ignores it.
|
|
55
|
+
*/
|
|
56
|
+
export function noteFreshness(note) {
|
|
57
|
+
const current = caseSha256(note.caseId);
|
|
58
|
+
if (current === null)
|
|
59
|
+
return { state: 'no-case', reason: 'the case file does not exist' };
|
|
60
|
+
if (current !== note.caseSha256)
|
|
61
|
+
return { state: 'stale', reason: 'the case file changed after the note was written' };
|
|
62
|
+
return { state: 'fresh' };
|
|
63
|
+
}
|
|
64
|
+
function checkLocator(value, where, problems) {
|
|
65
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
66
|
+
problems.push(`${where} must be a locator string`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!isOneLine(value))
|
|
70
|
+
problems.push(`${where} must be one line`);
|
|
71
|
+
if (value.length > MAX_LOCATOR)
|
|
72
|
+
problems.push(`${where} is longer than ${MAX_LOCATOR} characters`);
|
|
73
|
+
if (!value.startsWith('page.'))
|
|
74
|
+
problems.push(`${where} must start with "page." (use browser_generate_locator)`);
|
|
75
|
+
if (value.includes('=>'))
|
|
76
|
+
problems.push(`${where} must be a locator, not code`);
|
|
77
|
+
}
|
|
78
|
+
function checkRequest(value, where, problems) {
|
|
79
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
80
|
+
problems.push(`${where} must be a request path`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!isOneLine(value))
|
|
84
|
+
problems.push(`${where} must be one line`);
|
|
85
|
+
if (value.length > MAX_REQUEST)
|
|
86
|
+
problems.push(`${where} is longer than ${MAX_REQUEST} characters`);
|
|
87
|
+
if (!value.startsWith('/') || value.startsWith('//'))
|
|
88
|
+
problems.push(`${where} must start with "/" (a path, not a URL)`);
|
|
89
|
+
if (UNSAFE_REQUEST.test(value))
|
|
90
|
+
problems.push(`${where} must have no host, query string or fragment`);
|
|
91
|
+
if (SECRET_PARAM.test(value))
|
|
92
|
+
problems.push(`${where} must not carry a token, cookie or session`);
|
|
93
|
+
}
|
|
94
|
+
function checkPurpose(value, where, problems) {
|
|
95
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
96
|
+
problems.push(`${where} must be a short description`);
|
|
97
|
+
else if (!isOneLine(value))
|
|
98
|
+
problems.push(`${where} must be one line`);
|
|
99
|
+
else if (value.length > MAX_PURPOSE)
|
|
100
|
+
problems.push(`${where} is longer than ${MAX_PURPOSE} characters`);
|
|
101
|
+
}
|
|
102
|
+
function checkSteps(value, problems) {
|
|
103
|
+
if (!isRecord(value)) {
|
|
104
|
+
problems.push('steps must be an object keyed by step number');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [step, body] of Object.entries(value)) {
|
|
108
|
+
const where = `steps["${step}"]`;
|
|
109
|
+
if (!STEP_KEY.test(step))
|
|
110
|
+
problems.push(`${where}: the key must be a positive step number`);
|
|
111
|
+
if (!isRecord(body)) {
|
|
112
|
+
problems.push(`${where} must be an object`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (body.url !== undefined) {
|
|
116
|
+
const url = body.url;
|
|
117
|
+
if (typeof url !== 'string' || !url.startsWith('/'))
|
|
118
|
+
problems.push(`${where}.url must start with "/"`);
|
|
119
|
+
else if (UNSAFE_REQUEST.test(url))
|
|
120
|
+
problems.push(`${where}.url must have no host, query string or fragment`);
|
|
121
|
+
}
|
|
122
|
+
if (body.locators !== undefined) {
|
|
123
|
+
if (!Array.isArray(body.locators))
|
|
124
|
+
problems.push(`${where}.locators must be a list`);
|
|
125
|
+
else {
|
|
126
|
+
for (const [index, locator] of body.locators.entries()) {
|
|
127
|
+
const item = `${where}.locators[${index}]`;
|
|
128
|
+
if (!isRecord(locator)) {
|
|
129
|
+
problems.push(`${item} must be an object with "purpose" and "locator"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
checkPurpose(locator.purpose, `${item}.purpose`, problems);
|
|
133
|
+
checkLocator(locator.locator, `${item}.locator`, problems);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (body.requests !== undefined) {
|
|
138
|
+
if (!Array.isArray(body.requests))
|
|
139
|
+
problems.push(`${where}.requests must be a list`);
|
|
140
|
+
else
|
|
141
|
+
body.requests.forEach((request, index) => checkRequest(request, `${where}.requests[${index}]`, problems));
|
|
142
|
+
}
|
|
143
|
+
const known = new Set(['url', 'locators', 'requests']);
|
|
144
|
+
for (const key of Object.keys(body))
|
|
145
|
+
if (!known.has(key))
|
|
146
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function checkTargets(value, problems) {
|
|
150
|
+
if (!isRecord(value)) {
|
|
151
|
+
problems.push('targets must be an object keyed by target id');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [id, target] of Object.entries(value)) {
|
|
155
|
+
const where = `targets["${id}"]`;
|
|
156
|
+
if (!TARGET_KEY.test(id))
|
|
157
|
+
problems.push(`${where}: the key must be a target id such as "T1"`);
|
|
158
|
+
if (!isRecord(target)) {
|
|
159
|
+
problems.push(`${where} must be an object`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
checkLocator(target.locator, `${where}.locator`, problems);
|
|
163
|
+
if (target.matches !== undefined && (!Number.isInteger(target.matches) || target.matches < 1)) {
|
|
164
|
+
problems.push(`${where}.matches must be a positive whole number`);
|
|
165
|
+
}
|
|
166
|
+
const known = new Set(['locator', 'matches']);
|
|
167
|
+
for (const key of Object.keys(target))
|
|
168
|
+
if (!known.has(key))
|
|
169
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Every reason the note is not usable, in plain words. An empty list means it is valid. */
|
|
173
|
+
export function validateExploreNote(value, expected) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
if (!isRecord(value))
|
|
176
|
+
return ['the note is not a JSON object'];
|
|
177
|
+
if (value.version !== NOTES_VERSION)
|
|
178
|
+
problems.push(`version must be ${NOTES_VERSION}`);
|
|
179
|
+
if (value.caseId !== expected.caseId)
|
|
180
|
+
problems.push(`caseId must be "${expected.caseId}"`);
|
|
181
|
+
if (value.profile !== expected.profile)
|
|
182
|
+
problems.push(`profile must be "${expected.profile}"`);
|
|
183
|
+
if (typeof value.profile !== 'string' || !PROFILE_FILE.test(value.profile)) {
|
|
184
|
+
problems.push('profile must be a profile id (letters, digits, ".", "_" and "-")');
|
|
185
|
+
}
|
|
186
|
+
if (typeof value.caseSha256 !== 'string' || !HASH.test(value.caseSha256)) {
|
|
187
|
+
problems.push('caseSha256 must be the sha256 of the case file (run "explore:notes <case-id> --hash")');
|
|
188
|
+
}
|
|
189
|
+
if (typeof value.capturedAt !== 'string' || value.capturedAt.trim() === '') {
|
|
190
|
+
problems.push('capturedAt must be an ISO date-time');
|
|
191
|
+
}
|
|
192
|
+
if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string')
|
|
193
|
+
problems.push('updatedAt must be a string');
|
|
194
|
+
if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
|
|
195
|
+
problems.push('env must be a non-empty string when it is present');
|
|
196
|
+
}
|
|
197
|
+
checkSteps(value.steps, problems);
|
|
198
|
+
if (value.targets !== undefined)
|
|
199
|
+
checkTargets(value.targets, problems);
|
|
200
|
+
const known = new Set(['version', 'caseId', 'profile', 'env', 'caseSha256', 'capturedAt', 'updatedAt', 'steps', 'targets']);
|
|
201
|
+
for (const key of Object.keys(value))
|
|
202
|
+
if (!known.has(key))
|
|
203
|
+
problems.push(`${key} is not a known field`);
|
|
204
|
+
return problems;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
208
|
+
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
|
209
|
+
*/
|
|
210
|
+
export function readExploreNotes(caseId, profileId) {
|
|
211
|
+
const errors = [];
|
|
212
|
+
const notes = [];
|
|
213
|
+
if (!isValidCaseId(caseId))
|
|
214
|
+
return { notes, errors: [{ file: caseId, problems: ['not a valid case id'] }] };
|
|
215
|
+
let files;
|
|
216
|
+
try {
|
|
217
|
+
files = fs.readdirSync(exploreNotesDir(caseId)).filter((file) => file.endsWith('.json'));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { notes, errors };
|
|
221
|
+
}
|
|
222
|
+
for (const file of files.sort()) {
|
|
223
|
+
const profile = path.basename(file, '.json');
|
|
224
|
+
if (profileId !== undefined && profile !== profileId)
|
|
225
|
+
continue;
|
|
226
|
+
const full = path.join(exploreNotesDir(caseId), file);
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
errors.push({ file: full, problems: [`not valid JSON: ${error.message}`] });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const problems = validateExploreNote(parsed, { caseId, profile });
|
|
236
|
+
if (problems.length > 0)
|
|
237
|
+
errors.push({ file: full, problems });
|
|
238
|
+
else
|
|
239
|
+
notes.push(parsed);
|
|
240
|
+
}
|
|
241
|
+
return { notes, errors };
|
|
242
|
+
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare const CASES_DIR: string;
|
|
|
25
25
|
export declare const TESTS_DIR: string;
|
|
26
26
|
export declare const REPORTS_DIR: string;
|
|
27
27
|
export declare const TMP_DIR: string;
|
|
28
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
29
|
+
export declare const EXPLORE_DIR: string;
|
|
28
30
|
export declare const TEMPLATES_DIR: string;
|
|
29
31
|
export declare const SKILLS_DIR: string;
|
|
30
32
|
/**
|
package/dist/lib/paths.js
CHANGED
|
@@ -82,6 +82,8 @@ export const CASES_DIR = path.join(ROOT, 'cases');
|
|
|
82
82
|
export const TESTS_DIR = path.join(ROOT, 'tests');
|
|
83
83
|
export const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
84
84
|
export const TMP_DIR = path.join(ROOT, '.tmp');
|
|
85
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
86
|
+
export const EXPLORE_DIR = path.join(ROOT, '.explore');
|
|
85
87
|
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
86
88
|
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
87
89
|
/**
|
package/dist/scripts/cli.js
CHANGED
|
@@ -32,13 +32,19 @@ async function main() {
|
|
|
32
32
|
process.exitCode = await migrate(rest);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
if (command === 'explore:notes') {
|
|
36
|
+
// The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
|
|
37
|
+
process.argv.splice(2, 1);
|
|
38
|
+
await import('./explore-notes.js');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
35
41
|
if (command === 'help' || command === '--help-init') {
|
|
36
42
|
console.log(USAGE);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
await import('./run.js');
|
|
40
46
|
}
|
|
41
|
-
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
47
|
+
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate|explore:notes] [options]
|
|
42
48
|
|
|
43
49
|
(no subcommand) Run the tests of one environment. See "pnpm asrun --help".
|
|
44
50
|
init Create the files a workspace needs (never overwrites; --update refreshes generated files,
|
|
@@ -50,5 +56,6 @@ const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
|
50
56
|
upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
|
|
51
57
|
target release for breaking changes (see "pnpm asrun upgrade --help").
|
|
52
58
|
migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
|
|
53
|
-
--help"). "upgrade" runs it; use it after a manual install
|
|
59
|
+
--help"). "upgrade" runs it; use it after a manual install.
|
|
60
|
+
explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").`;
|
|
54
61
|
await main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isValidCaseId } from '../lib/case-id.js';
|
|
3
|
+
import { caseSha256, exploreNotesDir, exploreNotesFile, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
|
|
4
|
+
import { ROOT } from '../lib/paths.js';
|
|
5
|
+
import { loadProfiles } from '../lib/profiles.js';
|
|
6
|
+
const USAGE = `Usage: pnpm explore:notes <case-id> [--profile <profile-id>] [--json] [--check] [--hash]
|
|
7
|
+
|
|
8
|
+
Prints the exploration notes /explore-case wrote for a case under .explore/notes/<case-id>/.
|
|
9
|
+
They are local, git-ignored and optional: /gen-spec reads a fresh one to verify instead of rediscovering the page,
|
|
10
|
+
and falls back to a full replay when it is missing or stale.
|
|
11
|
+
|
|
12
|
+
--profile <id> only this profile's note
|
|
13
|
+
--json the parsed notes (and any errors) as JSON, for a run that reads them
|
|
14
|
+
--check validate every note; fails on a malformed file, warns on a stale one
|
|
15
|
+
--hash print the case file's sha256, for "caseSha256" in a note
|
|
16
|
+
|
|
17
|
+
Paths are relative to the workspace root (${ROOT}).`;
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
let caseId;
|
|
20
|
+
let profile;
|
|
21
|
+
let json = false;
|
|
22
|
+
let check = false;
|
|
23
|
+
let hash = false;
|
|
24
|
+
let help = false;
|
|
25
|
+
for (let index = 0; index < args.length; index++) {
|
|
26
|
+
const arg = args[index];
|
|
27
|
+
if (arg === '--help' || arg === '-h')
|
|
28
|
+
help = true;
|
|
29
|
+
else if (arg === '--json')
|
|
30
|
+
json = true;
|
|
31
|
+
else if (arg === '--check')
|
|
32
|
+
check = true;
|
|
33
|
+
else if (arg === '--hash')
|
|
34
|
+
hash = true;
|
|
35
|
+
else if (arg === '--profile') {
|
|
36
|
+
const value = args[++index];
|
|
37
|
+
if (value === undefined || value.startsWith('--')) {
|
|
38
|
+
console.error(`--profile needs a profile id.\n\n${USAGE}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
profile = value;
|
|
42
|
+
}
|
|
43
|
+
else if (!arg.startsWith('--') && caseId === undefined)
|
|
44
|
+
caseId = arg;
|
|
45
|
+
else {
|
|
46
|
+
console.error(`Unexpected argument: ${arg}\n\n${USAGE}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (help) {
|
|
51
|
+
console.log(USAGE);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
if (caseId === undefined) {
|
|
55
|
+
console.error(USAGE);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (!isValidCaseId(caseId)) {
|
|
59
|
+
console.error(`"${caseId}" is not a valid case id (one optional group folder, for example documents/landing-page).`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (hash) {
|
|
63
|
+
const value = caseSha256(caseId);
|
|
64
|
+
if (value === null) {
|
|
65
|
+
console.error(`cases/${caseId}.md does not exist.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.log(value);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
const { notes, errors } = readExploreNotes(caseId, profile);
|
|
72
|
+
const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
|
|
73
|
+
if (json) {
|
|
74
|
+
console.log(JSON.stringify({
|
|
75
|
+
caseId,
|
|
76
|
+
notes: notes.map((note) => ({ ...note, freshness: noteFreshness(note) })),
|
|
77
|
+
errors: errors.map((error) => ({ file: relative(error.file), problems: error.problems })),
|
|
78
|
+
}, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(`Exploration notes for cases/${caseId}.md (${relative(exploreNotesDir(caseId))})`);
|
|
82
|
+
if (notes.length === 0)
|
|
83
|
+
console.log(' none yet: /gen-spec replays the case.');
|
|
84
|
+
for (const note of notes) {
|
|
85
|
+
console.log(` ${note.profile.padEnd(24)} ${describe(note)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let failed = false;
|
|
89
|
+
for (const error of errors) {
|
|
90
|
+
failed = true;
|
|
91
|
+
for (const problem of error.problems)
|
|
92
|
+
console.log(`FAIL ${relative(error.file)}: ${problem}`);
|
|
93
|
+
}
|
|
94
|
+
if (check) {
|
|
95
|
+
const value = caseSha256(caseId);
|
|
96
|
+
if (value === null) {
|
|
97
|
+
failed = true;
|
|
98
|
+
console.log(`FAIL cases/${caseId}.md does not exist`);
|
|
99
|
+
}
|
|
100
|
+
const known = new Set(knownProfiles());
|
|
101
|
+
for (const note of notes) {
|
|
102
|
+
const freshness = noteFreshness(note);
|
|
103
|
+
if (freshness.state !== 'fresh') {
|
|
104
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: ${freshness.reason}; current caseSha256 is ${value ?? '(unknown)'}`);
|
|
105
|
+
}
|
|
106
|
+
if (!known.has(note.profile)) {
|
|
107
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: "${note.profile}" is not in profiles.yaml`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (failed) {
|
|
111
|
+
console.error('\nA note that fails here is ignored by /gen-spec. Fix it, or delete it and let the case be replayed.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function describe(note) {
|
|
116
|
+
const freshness = noteFreshness(note);
|
|
117
|
+
const steps = Object.keys(note.steps)
|
|
118
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
119
|
+
.join(',');
|
|
120
|
+
const targets = Object.keys(note.targets ?? {}).sort().join(',');
|
|
121
|
+
const state = freshness.state === 'fresh' ? 'fresh' : `stale (${freshness.reason})`;
|
|
122
|
+
return `${state} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
function knownProfiles() {
|
|
125
|
+
try {
|
|
126
|
+
return loadProfiles().map((entry) => entry.id);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -223,5 +223,6 @@ In the framework package:
|
|
|
223
223
|
- `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
|
|
224
224
|
- `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
|
|
225
225
|
- `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
|
|
226
|
+
- `.claude/skills/exploring-cases/references/notes.md` — the local exploration note `/gen-spec` reads (not part of the case).
|
|
226
227
|
- `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
|
|
227
228
|
- `docs/cases.md` — the same format for people writing a case by hand.
|
|
@@ -53,6 +53,10 @@ is relevant to a project that uses asrun as a dependency.
|
|
|
53
53
|
- Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
|
|
54
54
|
assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
|
|
55
55
|
`FRAMEWORK_ROOT` at run time.
|
|
56
|
+
- `.explore/` is local state (git-ignored): target reference screenshots and the exploration notes that
|
|
57
|
+
`lib/explore-notes.ts` reads (`.explore/notes/<case-id>/<profile-id>.json`). Nothing there is committed, and a
|
|
58
|
+
workspace without it generates specs by replaying the case. `scripts/explore-notes.ts` (`pnpm explore:notes`) prints
|
|
59
|
+
and validates those notes.
|
|
56
60
|
- After changing `lib/`, run `pnpm build` before exercising the demo's specs.
|
|
57
61
|
- `pnpm build` copies `scripts/*.mjs` into `dist/scripts/`, where they sit one folder deeper than in this repository.
|
|
58
62
|
`pnpm verify:pack` (part of CI) checks that layout on a packed tarball; the checkout alone hides such bugs, because
|
package/docs/ai-workflow.md
CHANGED
|
@@ -83,7 +83,10 @@ publishes, shares or deletes anything, in staging or in production. It:
|
|
|
83
83
|
- turns what it cannot know into `🚫 TODO [blocking]` / `❓ TODO [non-blocking]` questions, each under a sentence that
|
|
84
84
|
is its suggestion and can stand in the case as it is, with a last line `- ✍️ Answer:`;
|
|
85
85
|
- lists the candidates instead of guessing a target (`## Targets`);
|
|
86
|
-
- edits the case file
|
|
86
|
+
- edits the case file and the `.explore/` files (reference screenshots and the note below), and nothing else;
|
|
87
|
+
- writes a local **exploration note** (`.explore/notes/<case-id>/<profile-id>.json`, git-ignored) with the locators and
|
|
88
|
+
data-call paths it saw. `/gen-spec` reads it to verify instead of rediscovering, and a page that only exists after a
|
|
89
|
+
write step is written from it. Delete it at any time: without it, generation replays the case.
|
|
87
90
|
|
|
88
91
|
With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated). The others are not
|
|
89
92
|
opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
|
|
@@ -138,6 +141,11 @@ subset adds up: explore A and B first and C later, and `/gen-spec` covers all th
|
|
|
138
141
|
about runs on `default` alone, and the report names it. A profile that is not logged in is not dropped: its part is
|
|
139
142
|
written from the case and marked `not verified on the page`.
|
|
140
143
|
|
|
144
|
+
It reads each profile's **exploration note** first: a fresh note supplies the locators and request patterns, which it
|
|
145
|
+
confirms on the page instead of rediscovering; a missing or stale note means a full replay. A page that only exists
|
|
146
|
+
after a write step is written from a fresh note. `pnpm explore:notes <case-id> [--profile <id>]` shows what was
|
|
147
|
+
recorded, and `--check` validates it.
|
|
148
|
+
|
|
141
149
|
It does **not** run the spec and does **not** create screenshot baselines: running is a separate step (6).
|
|
142
150
|
|
|
143
151
|
- A case must be in the project's artifact language. If it is in another language, `/gen-spec` translates the case
|
package/docs/cases.md
CHANGED
|
@@ -207,7 +207,7 @@ that cannot be answered in place.
|
|
|
207
207
|
|
|
208
208
|
## Rename or move a case
|
|
209
209
|
|
|
210
|
-
Use the Cases page (
|
|
211
|
-
spec is rewritten to match. By hand, move the three things together
|
|
212
|
-
`*-snapshots/`), and keep the spec's `// case:` line and its imports
|
|
213
|
-
name, so their folder depth does not change the imports.
|
|
210
|
+
Use the Cases page (open the case, then **Rename or move** in the **⋯** menu at the end of the tab strip). The spec
|
|
211
|
+
and the baseline images move with it and the spec is rewritten to match. By hand, move the three things together
|
|
212
|
+
(`cases/...md`, `tests/...spec.ts`, its `*-snapshots/`), and keep the spec's `// case:` line and its imports
|
|
213
|
+
unchanged: specs import the framework by package name, so their folder depth does not change the imports.
|
package/docs/commands.md
CHANGED
|
@@ -18,6 +18,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
|
|
|
18
18
|
| Run tests | `pnpm asrun --env <env> [filters]` |
|
|
19
19
|
| Open the last report | `pnpm report` |
|
|
20
20
|
| Look around a page as a profile | `pnpm explore <profile-id> [path]` |
|
|
21
|
+
| See or validate what an exploration recorded for a case | `pnpm explore:notes <case-id> [--profile <id>] [--json] [--check]` |
|
|
21
22
|
| Read what the last report says about a case | `pnpm heal:evidence --case <case-id> --env <env>` |
|
|
22
23
|
| List or forget test data that could not be removed | `pnpm cleanup --env <env> [--forget]` |
|
|
23
24
|
| Create or validate a case | `pnpm case:new <id>`, `pnpm case:check [case-id]` |
|
|
@@ -141,6 +142,9 @@ pnpm pdf:render report.pdf --out .explore/targets/header-logo --corner top-left
|
|
|
141
142
|
|
|
142
143
|
```bash
|
|
143
144
|
pnpm explore example-staging /app/home
|
|
145
|
+
pnpm explore:notes conduit/header-navigation
|
|
146
|
+
pnpm explore:notes conduit/header-navigation --profile example-staging --json
|
|
147
|
+
pnpm explore:notes conduit/header-navigation --check
|
|
144
148
|
pnpm heal:evidence --case header-logo --env staging
|
|
145
149
|
```
|
|
146
150
|
|
|
@@ -149,6 +153,13 @@ are saved to `~/Downloads` and never overwrite an existing file. `heal:evidence`
|
|
|
149
153
|
step, error, call log and screenshots for a case, and whether the report is stale; it is what `/heal-spec` reads
|
|
150
154
|
first.
|
|
151
155
|
|
|
156
|
+
`explore:notes` reads the **exploration note** that `/explore-case` writes for a case under
|
|
157
|
+
`.explore/notes/<case-id>/<profile-id>.json`. It prints one line per profile (fresh or stale, the steps and targets it
|
|
158
|
+
covers) and `--json` prints the parsed notes for a run that reads them. A note is local, git-ignored and optional:
|
|
159
|
+
`/gen-spec` uses a fresh one to verify the locators and request patterns instead of rediscovering them, and replays the
|
|
160
|
+
case when it is missing or stale. `--check` fails on a malformed note and warns about a stale one (it prints the current
|
|
161
|
+
`caseSha256`); `--hash` prints that hash. Deleting `.explore/notes/` is always safe.
|
|
162
|
+
|
|
152
163
|
## Cleanup
|
|
153
164
|
|
|
154
165
|
```bash
|
package/docs/theme-guide.html
CHANGED
|
@@ -330,6 +330,7 @@
|
|
|
330
330
|
<a href="#icons">Icons</a>
|
|
331
331
|
<a href="#tooltips">Tooltips</a>
|
|
332
332
|
<a href="#editor">Markdown editor</a>
|
|
333
|
+
<a href="#document-tabs">Document tabs</a>
|
|
333
334
|
<a href="#surfaces">Surfaces</a>
|
|
334
335
|
<a href="#sections">Sidebar sections</a>
|
|
335
336
|
<a href="#checklist">Checklist</a>
|
|
@@ -959,6 +960,61 @@ import {FilterChips} from '../components/filter-chips';
|
|
|
959
960
|
<li><b>Every expand/collapse animates.</b> Use <code>Collapse</code> from <code>components/collapse.tsx</code> (height and fade, 200ms, <code>ease-out</code>) for content that appears and disappears, and rotate the chevron with <code>transition-transform duration-200</code> instead of swapping two icons. Always add <code>motion-reduce:transition-none</code>. Turn transitions off while the user drags a resize handle.</li>
|
|
960
961
|
</ul>
|
|
961
962
|
|
|
963
|
+
<h2 id="document-tabs">Document tabs</h2>
|
|
964
|
+
<p class="muted">
|
|
965
|
+
A page that holds more than one file of the same thing puts them in a tab strip: <code>DocumentTabs</code> in
|
|
966
|
+
<code>pages/cases.tsx</code>, where <b>Case</b> is the source a person writes and <b>Spec</b> what a run
|
|
967
|
+
generated from it. The strip is the only place the file names appear. The bar under it carries the tools of the
|
|
968
|
+
file on screen (<code>WorkbenchControls</code>, the layout and pane switches), never a second copy of the name,
|
|
969
|
+
and <b>Save</b> / <b>Discard</b> always sit at the same end of the strip and act on the file on screen.
|
|
970
|
+
</p>
|
|
971
|
+
<div class="demo" style="align-items:flex-end;gap:16px;padding:12px 16px 0;background:var(--warm);border-bottom:1px solid var(--line-mid)">
|
|
972
|
+
<span style="display:flex;align-items:center;gap:8px;margin-bottom:-1px;padding:6px 12px;border-bottom:2px solid var(--theme-btn);border-radius:6px 6px 0 0;background:var(--card);font-size:14px">
|
|
973
|
+
<span style="font-size:12px;font-weight:500;letter-spacing:.05em;color:var(--theme-ink)">Case</span>
|
|
974
|
+
<span class="mono">favorite-article.md</span>
|
|
975
|
+
<span title="Unsaved changes" style="width:6px;height:6px;border-radius:9999px;background:var(--gold-deep)"></span>
|
|
976
|
+
</span>
|
|
977
|
+
<span style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:2px solid transparent;border-radius:6px 6px 0 0;font-size:14px;color:var(--ink-2)">
|
|
978
|
+
<span style="font-size:12px;font-weight:500;letter-spacing:.05em;color:var(--ink-3)">Spec</span>
|
|
979
|
+
<span class="mono">favorite-article.spec.ts</span>
|
|
980
|
+
</span>
|
|
981
|
+
<span style="display:flex;align-items:center;gap:8px;margin-left:auto;padding-bottom:8px">
|
|
982
|
+
<span class="btn btn-secondary">Discard</span>
|
|
983
|
+
<span class="btn btn-primary">Save</span>
|
|
984
|
+
<span class="btn btn-secondary">...</span>
|
|
985
|
+
</span>
|
|
986
|
+
</div>
|
|
987
|
+
<div class="demo" style="gap:16px;background:var(--card);border-bottom:1px solid var(--line-mid);padding:8px 16px">
|
|
988
|
+
<span style="display:flex;align-items:center;gap:6px">
|
|
989
|
+
<span style="font-size:12px;color:var(--ink-3)">Layout</span>
|
|
990
|
+
<span style="display:flex;overflow:hidden;border-radius:4px;box-shadow:0 0 0 1px var(--line-mid)">
|
|
991
|
+
<span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Split</span>
|
|
992
|
+
<span style="padding:4px 10px;font-size:14px;background:var(--theme-btn);color:#fff">Single</span>
|
|
993
|
+
</span>
|
|
994
|
+
</span>
|
|
995
|
+
<span style="display:flex;align-items:center;gap:6px">
|
|
996
|
+
<span style="font-size:12px;color:var(--ink-3)">Pane</span>
|
|
997
|
+
<span style="display:flex;overflow:hidden;border-radius:4px;box-shadow:0 0 0 1px var(--line-mid)">
|
|
998
|
+
<span style="padding:4px 10px;font-size:14px;background:var(--theme-btn);color:#fff">Panel</span>
|
|
999
|
+
<span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Editor</span>
|
|
1000
|
+
<span style="padding:4px 10px;font-size:14px;background:var(--card);color:var(--ink-2)">Doc</span>
|
|
1001
|
+
</span>
|
|
1002
|
+
</span>
|
|
1003
|
+
</div>
|
|
1004
|
+
<ul class="tight">
|
|
1005
|
+
<li>The active tab is <code>bg-card</code> with a <code>border-b-2 border-theme-btn</code> underline; the others are
|
|
1006
|
+
<code>text-ink-2</code> on the warm strip. The name is <code>font-mono</code>; the kind in front of it is
|
|
1007
|
+
<code>text-xs tracking-wider</code>, <code>text-theme-ink</code> when active and <code>text-ink-3</code> otherwise.</li>
|
|
1008
|
+
<li>Unsaved changes are a 6px <code>bg-gold-deep</code> dot on the tab, never a badge in the page header. A file
|
|
1009
|
+
that does not exist yet keeps its tab, disabled at 45% opacity, with a tooltip that says how to create it.</li>
|
|
1010
|
+
<li>The switches of the bar under the tabs are a segmented control, not buttons: active <code>bg-theme-btn</code>
|
|
1011
|
+
with white text, the others <code>bg-card</code>, the whole group ringed with <code>ring-1 ring-line-mid</code>, and
|
|
1012
|
+
a <code>text-xs text-ink-3</code> label in front (<code>Layout</code>, <code>Pane</code>). A switch group without a
|
|
1013
|
+
label is not discoverable; never ship one.</li>
|
|
1014
|
+
<li>Switching tabs keeps the other tab's draft, so <b>Save</b> and <b>Discard</b> take the active file; the tab
|
|
1015
|
+
dot is what says another file still has unsaved changes.</li>
|
|
1016
|
+
</ul>
|
|
1017
|
+
|
|
962
1018
|
<h2 id="sections">Sidebar sections and selection</h2>
|
|
963
1019
|
<div class="row" style="align-items:flex-start">
|
|
964
1020
|
<div class="side">
|
package/docs/web-ui.md
CHANGED
|
@@ -55,9 +55,26 @@ One row per profile, with the same login check as `pnpm status`.
|
|
|
55
55
|
|
|
56
56
|
## Cases
|
|
57
57
|
|
|
58
|
-
The case list is grouped by folder and collapses.
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
The case list is grouped by folder and collapses. A case opens on the **Case** tab, with the **Spec** tab beside it:
|
|
59
|
+
|
|
60
|
+
- **Case** (`favorite-article.md`) is the source a person writes: the steps, values, rules and questions.
|
|
61
|
+
- **Spec** (`favorite-article.spec.ts`) is what a generate or heal run wrote from it. It stays visible but disabled
|
|
62
|
+
until the case has a spec, and its tooltip says how to make one.
|
|
63
|
+
|
|
64
|
+
The tab is the page's state: it lives in the URL (`?view=spec`), so a reload, a shared link and the Back button land on
|
|
65
|
+
the same file, and every other link opens the case. Each tab keeps its own unsaved draft while you switch between them;
|
|
66
|
+
an amber dot on a tab means that file has unsaved changes, and **Save** (`Ctrl/Cmd+S`) and **Discard** always sit at the
|
|
67
|
+
right of the tab strip and act on the file on screen.
|
|
68
|
+
|
|
69
|
+
Under the tabs, the first row is the AI toolbar (command, environment, profiles, attended, **Run AI**, copy the shell
|
|
70
|
+
command, AI guide). It is the same on both tabs, because every AI command acts on the case. The row below holds the
|
|
71
|
+
tools of the file on screen: on **Case** the **Layout** (Split / Single) and **Pane** (Panel / Editor / Doc) switches
|
|
72
|
+
and the previous / next of an outstanding mark; on **Spec** the line that says the file is generated from the case
|
|
73
|
+
(with a link back to it), **Open in editor**, **Copy path** and copy the spec. **Rename or move** and **Delete** live
|
|
74
|
+
in the **⋯** menu at the end of the tab strip.
|
|
75
|
+
|
|
76
|
+
The editor opens in **Single** with the **Panel** full width; **Split** puts the source on the left and one pane on the
|
|
77
|
+
right (drag the divider to resize, double-click to reset).
|
|
61
78
|
|
|
62
79
|
- **Source and preview** scroll together, the preview block at the caret is shaded, and a click in the preview puts
|
|
63
80
|
the caret on that line. `Ctrl/Cmd+S` saves.
|
|
@@ -84,15 +101,19 @@ Single, between **Panel**, **Editor** and **Doc**.
|
|
|
84
101
|
when it saved one, the **screenshot** from the exploration, so you look at the image instead of opening the page
|
|
85
102
|
again. Rows that change an assertion are highlighted; **Confirm** settles one line and **Confirm all** settles the
|
|
86
103
|
list in one undo step. None of this blocks generating a spec; healing stops until the case is settled.
|
|
87
|
-
- **Spec view** is editable too (`Ctrl/Cmd+S`; refused when the file changed on disk meanwhile)
|
|
88
|
-
opens it in the default application for `.spec.ts`, **Copy
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
- **Spec view** is editable too (`Ctrl/Cmd+S`; refused when the file changed on disk meanwhile), and its tab links back
|
|
105
|
+
to the case it was generated from. **Open in editor** opens it in the default application for `.spec.ts`, **Copy
|
|
106
|
+
path** copies its absolute path.
|
|
107
|
+
- **Rename or move** (in the **⋯** menu) keeps the spec and the baselines with the case and rewrites the spec;
|
|
108
|
+
**Delete** (type the id, or fill it in with the feather icon) removes the case, its spec and its baselines. Both
|
|
109
|
+
dialogs list exactly what they touch, and are refused while an AI or test run is in progress or while a draft is
|
|
110
|
+
unsaved.
|
|
111
|
+
- **AI toolbar:** command (explore / generate / heal, explore by default), **Environment** (required, no default),
|
|
93
112
|
**Profiles** (explore only: every profile that is logged in is ticked, the choice is remembered per environment, and
|
|
94
113
|
only the ticked ones are opened), **Attended** (only for explore, ticked by default), **Run AI**, copy the shell
|
|
95
114
|
command, open the AI guide.
|
|
115
|
+
- **Writes in …** is one line under the toolbar, in red: the environments whose data the case writes, then its note,
|
|
116
|
+
with the full note in its tooltip. A case that only runs in some environments shows that line instead.
|
|
96
117
|
- **From memo** opens a Markdown editor with a preview for raw notes. **Make cases** saves them to `.tmp/memos/` and
|
|
97
118
|
runs `/make-case` one-shot (defaults for gaps, `TODO` for unknown values, no page exploration, existing cases never
|
|
98
119
|
overwritten); **Copy /make-case** copies the memo for the interactive dialogue. When the run ends, **What to do
|