@asrunjs/asrun 0.2.0 → 0.5.3
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/commands/explore-case.md +18 -15
- package/.claude/commands/gen-spec.md +16 -13
- package/.claude/commands/heal-spec.md +26 -23
- package/.claude/commands/make-case.md +11 -8
- package/.claude/skills/exploring-cases/SKILL.md +146 -132
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
- package/.claude/skills/exploring-cases/references/readiness.md +146 -137
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
- package/.claude/skills/generating-specs/SKILL.md +219 -207
- package/.claude/skills/generating-specs/references/conventions.md +148 -148
- package/.claude/skills/making-cases/SKILL.md +72 -64
- package/.claude/skills/making-cases/references/normalizing.md +39 -38
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
- package/AGENTS.md +169 -154
- package/CLAUDE.md +2 -1
- package/README.md +221 -410
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/cases.d.ts +2 -15
- package/dist/lib/cases.js +5 -78
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/fixtures.js +2 -2
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +6 -2
- package/dist/lib/node-cli.js +16 -6
- package/dist/lib/profiles.d.ts +25 -1
- package/dist/lib/profiles.js +54 -1
- package/dist/lib/run-filters.js +10 -2
- package/dist/lib/session.d.ts +13 -1
- package/dist/lib/session.js +73 -7
- package/dist/lib/targets.d.ts +5 -13
- package/dist/lib/targets.js +4 -29
- package/dist/lib/values.js +2 -2
- package/dist/scripts/build.js +2 -1
- package/dist/scripts/check-cases.js +74 -6
- package/dist/scripts/doctor.js +115 -4
- package/dist/scripts/explore.js +22 -1
- package/dist/scripts/go.mjs +188 -188
- package/dist/scripts/init.d.ts +7 -1
- package/dist/scripts/init.js +62 -14
- package/dist/scripts/lint-specs.js +4 -1
- package/dist/scripts/login.js +30 -2
- package/dist/ui/server/actions.d.ts +4 -0
- package/dist/ui/server/actions.js +25 -12
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +34 -9
- package/dist/ui/server/ai-stream.js +283 -77
- package/dist/ui/server/app.js +74 -12
- package/dist/ui/server/jobs.d.ts +7 -1
- package/dist/ui/server/jobs.js +69 -15
- package/dist/ui/server/profiles.d.ts +20 -1
- package/dist/ui/server/profiles.js +214 -38
- package/dist/ui/shared/ai-commands.d.ts +16 -6
- package/dist/ui/shared/ai-commands.js +35 -12
- package/dist/ui/shared/types.d.ts +114 -0
- package/docs/agents/case-format.md +193 -0
- package/docs/agents/framework-dev.md +167 -0
- package/docs/ai-workflow.md +235 -0
- package/docs/cases.md +189 -0
- package/docs/commands.md +175 -0
- package/docs/install.md +174 -0
- package/docs/profiles.md +139 -0
- package/docs/theme-guide.html +922 -692
- package/docs/troubleshooting.md +101 -0
- package/docs/web-ui.md +139 -0
- package/package.json +20 -1
- package/templates/.claude/commands/explore-case.md +22 -21
- package/templates/.claude/commands/gen-spec.md +21 -20
- package/templates/.claude/commands/heal-spec.md +31 -30
- package/templates/.claude/commands/make-case.md +13 -12
- package/templates/.mcp.json +8 -8
- package/templates/AGENTS.md +66 -45
- package/templates/cases/_template.md +57 -63
- package/templates/opencode.jsonc +16 -11
- package/templates/profiles.yaml +42 -29
- package/ui/dist/assets/{dist-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
- package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
- package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
- package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
- package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
- package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
- package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
- package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
- package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
- package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
- package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +22 -16
- package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
- package/ui/dist/assets/index-I7uU93kk.js +0 -294
package/dist/lib/run-filters.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/** `--grep` pattern that matches a tag exactly (`@smoke` must not match `@smoke-extra`). */
|
|
2
2
|
const tagPattern = (name) => `@${name}(?![\\w-])`;
|
|
3
3
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4
|
+
/**
|
|
5
|
+
* Playwright matches `--grep` against a title that holds the spec file with the separator of this platform
|
|
6
|
+
* (`conduit/header-navigation.spec.ts` on macOS and Linux, `conduit\header-navigation.spec.ts` on Windows), so a
|
|
7
|
+
* `/` in a case id or in the user's search has to match either one. `\/` is left alone: it is already a plain `/`.
|
|
8
|
+
*/
|
|
9
|
+
const bothSeparators = (text) => text.replace(/(?<!\\)\//g, '[\\\\/]');
|
|
4
10
|
/** Matches the spec file of one case (`documents/published-flow` must not match `documents/published-flow-2`). */
|
|
5
|
-
export const casePattern = (id) => `(?:^|\\s)${escapeRegExp(id)}\\.spec\\.ts`;
|
|
11
|
+
export const casePattern = (id) => `(?:^|\\s)${bothSeparators(escapeRegExp(id))}\\.spec\\.ts`;
|
|
6
12
|
/**
|
|
7
13
|
* The Playwright arguments that decide WHICH tests run. Shared by `pnpm asrun` and by the web UI's
|
|
8
14
|
* "what would this run do" preview, so both always agree.
|
|
@@ -15,7 +21,9 @@ export function filterArgs(filters) {
|
|
|
15
21
|
grepParts.push(`(?:${filters.suites.map(tagPattern).join('|')})`);
|
|
16
22
|
}
|
|
17
23
|
if (filters.grep) {
|
|
18
|
-
|
|
24
|
+
// A case id (`conduit/header-navigation`) and a part of one (`conduit/head`) both have to match the spec file
|
|
25
|
+
// in Playwright's title, whatever separator this platform puts in it.
|
|
26
|
+
grepParts.push(`(?:${bothSeparators(filters.grep)})`);
|
|
19
27
|
}
|
|
20
28
|
if (filters.only && filters.only.length > 0) {
|
|
21
29
|
grepParts.push(`(?:${filters.only.map(casePattern).join('|')})`);
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Browser } from '@playwright/test';
|
|
2
|
-
import type
|
|
2
|
+
import { type Profile, type SessionCheck } from './profiles.js';
|
|
3
3
|
export type SessionStatus = 'ok' | 'missing' | 'expired';
|
|
4
4
|
/**
|
|
5
5
|
* Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
|
|
@@ -7,7 +7,19 @@ export type SessionStatus = 'ok' | 'missing' | 'expired';
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const NOT_LOGGED_IN_ENV = "ASRUN_NOT_LOGGED_IN";
|
|
9
9
|
export declare const hasSessionFile: (profile: Profile) => boolean;
|
|
10
|
+
/** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
|
|
11
|
+
export declare function isLoggedOutUrl(url: string, check?: SessionCheck): boolean;
|
|
12
|
+
/** Kept for callers that only ask about the conventional `/login` address. */
|
|
10
13
|
export declare const isLoginUrl: (url: string) => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
|
|
16
|
+
*
|
|
17
|
+
* A site can show "signed out" in more than one way, so every signal the profile names is watched at the
|
|
18
|
+
* same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
|
|
19
|
+
* has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
|
|
20
|
+
* page is read only after a signal arrived, never right after the navigation (which would still show the
|
|
21
|
+
* page before a client-side redirect happened).
|
|
22
|
+
*/
|
|
11
23
|
export declare function checkSession(browser: Browser, profile: Profile): Promise<SessionStatus>;
|
|
12
24
|
export declare function parseNotLoggedIn(value?: string | undefined): Map<string, SessionStatus>;
|
|
13
25
|
export declare const formatNotLoggedIn: (entries: Map<string, SessionStatus>) => string;
|
package/dist/lib/session.js
CHANGED
|
@@ -1,35 +1,101 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { DEFAULT_SESSION_CHECK } from './profiles.js';
|
|
2
3
|
import { authFile } from './paths.js';
|
|
3
4
|
/**
|
|
4
5
|
* Set by `pnpm asrun` after its pre-run login check: `profile-id:status,profile-id:status`
|
|
5
6
|
* for every profile that is not logged in. Lets the config and fixtures skip a second check.
|
|
6
7
|
*/
|
|
7
8
|
export const NOT_LOGGED_IN_ENV = 'ASRUN_NOT_LOGGED_IN';
|
|
9
|
+
/** A response that means "you are not allowed in", for sites that answer with a status instead of a redirect. */
|
|
10
|
+
const EXPIRED_STATUSES = new Set([401, 403]);
|
|
8
11
|
export const hasSessionFile = (profile) => fs.existsSync(authFile(profile.id));
|
|
9
|
-
|
|
12
|
+
/** True when the address is (or starts below) the address a site sends a signed-out visitor to. */
|
|
13
|
+
export function isLoggedOutUrl(url, check = DEFAULT_SESSION_CHECK) {
|
|
14
|
+
let target;
|
|
10
15
|
try {
|
|
11
|
-
|
|
16
|
+
target = new URL(url);
|
|
12
17
|
}
|
|
13
18
|
catch {
|
|
14
19
|
return false;
|
|
15
20
|
}
|
|
16
|
-
|
|
21
|
+
if (/^https?:\/\//i.test(check.loggedOutUrl)) {
|
|
22
|
+
try {
|
|
23
|
+
const expected = new URL(check.loggedOutUrl);
|
|
24
|
+
return target.origin === expected.origin && underPath(target.pathname, expected.pathname);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return underPath(target.pathname, check.loggedOutUrl);
|
|
31
|
+
}
|
|
32
|
+
const underPath = (pathname, expected) => pathname === expected || pathname.startsWith(expected.endsWith('/') ? expected : `${expected}/`);
|
|
33
|
+
/** Kept for callers that only ask about the conventional `/login` address. */
|
|
34
|
+
export const isLoginUrl = (url) => isLoggedOutUrl(url);
|
|
35
|
+
/**
|
|
36
|
+
* Opens `sessionCheckPath` with the saved session and decides whether the profile is still signed in.
|
|
37
|
+
*
|
|
38
|
+
* A site can show "signed out" in more than one way, so every signal the profile names is watched at the
|
|
39
|
+
* same time: a redirect to `sessionCheck.loggedOutUrl` (server side or, in a single-page app, after the app
|
|
40
|
+
* has started), a 401/403 response, `loggedOutSelector`, or `loggedInSelector` proving the opposite. The
|
|
41
|
+
* page is read only after a signal arrived, never right after the navigation (which would still show the
|
|
42
|
+
* page before a client-side redirect happened).
|
|
43
|
+
*/
|
|
17
44
|
export async function checkSession(browser, profile) {
|
|
18
|
-
if (!hasSessionFile(profile))
|
|
45
|
+
if (!hasSessionFile(profile))
|
|
19
46
|
return 'missing';
|
|
20
|
-
|
|
47
|
+
const check = profile.sessionCheck;
|
|
21
48
|
const context = await browser.newContext({ storageState: authFile(profile.id) });
|
|
22
49
|
try {
|
|
23
50
|
const page = await context.newPage();
|
|
24
|
-
await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
|
|
51
|
+
const response = await page.goto(new URL(profile.sessionCheckPath, profile.baseURL).href, {
|
|
25
52
|
waitUntil: 'domcontentloaded',
|
|
26
53
|
});
|
|
27
|
-
|
|
54
|
+
if (response && EXPIRED_STATUSES.has(response.status()))
|
|
55
|
+
return 'expired';
|
|
56
|
+
if (isLoggedOutUrl(page.url(), check))
|
|
57
|
+
return 'expired';
|
|
58
|
+
const signal = await firstSignal(page, check);
|
|
59
|
+
if (signal)
|
|
60
|
+
return signal;
|
|
61
|
+
// Nothing showed up: fall back to the address, which is all a plain redirect leaves behind. A profile
|
|
62
|
+
// that named a signed-in marker gets an error instead, so a wrong check path is visible instead of "ok".
|
|
63
|
+
if (isLoggedOutUrl(page.url(), check))
|
|
64
|
+
return 'expired';
|
|
65
|
+
if (check.loggedInSelector) {
|
|
66
|
+
throw new Error(`${profile.sessionCheckPath} neither showed \`${check.loggedInSelector}\` nor went to ${check.loggedOutUrl} ` +
|
|
67
|
+
`within ${check.timeoutMs} ms, so the session could not be checked. Fix "sessionCheck" in profiles.yaml.`);
|
|
68
|
+
}
|
|
69
|
+
return 'ok';
|
|
28
70
|
}
|
|
29
71
|
finally {
|
|
30
72
|
await context.close();
|
|
31
73
|
}
|
|
32
74
|
}
|
|
75
|
+
/** The first signal that arrives wins; null when every one of them times out. */
|
|
76
|
+
async function firstSignal(page, check) {
|
|
77
|
+
const timeout = check.timeoutMs;
|
|
78
|
+
const signals = [
|
|
79
|
+
page
|
|
80
|
+
.waitForURL((url) => isLoggedOutUrl(url.href, check), { timeout, waitUntil: 'commit' })
|
|
81
|
+
.then(() => 'expired'),
|
|
82
|
+
];
|
|
83
|
+
if (check.loggedOutSelector) {
|
|
84
|
+
signals.push(page
|
|
85
|
+
.waitForSelector(check.loggedOutSelector, { state: 'visible', timeout })
|
|
86
|
+
.then(() => 'expired'));
|
|
87
|
+
}
|
|
88
|
+
if (check.loggedInSelector) {
|
|
89
|
+
signals.push(page.waitForSelector(check.loggedInSelector, { state: 'visible', timeout }).then(() => 'ok'));
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await Promise.any(signals);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Every signal timed out: the caller decides what an inconclusive page means.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
33
99
|
export function parseNotLoggedIn(value = process.env[NOT_LOGGED_IN_ENV]) {
|
|
34
100
|
const result = new Map();
|
|
35
101
|
for (const entry of (value ?? '').split(',').filter(Boolean)) {
|
package/dist/lib/targets.d.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
+
import { type CaseTargetCard } from './case-doc.js';
|
|
1
2
|
/**
|
|
2
3
|
* An assertion target card: the exact image, text, file or place that a check looks at, written in the case under
|
|
3
4
|
* `## Targets` as `### T<n> <name>` with `- What:`, `- Where:`, `- Identified by:` and `- Reference <profile-id>:` bullets.
|
|
5
|
+
* Parsing lives in `lib/case-doc.ts` (pure), so the web panel reads the same cards.
|
|
4
6
|
*/
|
|
5
|
-
export
|
|
6
|
-
/** `T1`, `T2`, ... */
|
|
7
|
-
id: string;
|
|
8
|
-
name: string;
|
|
9
|
-
/** Field name (lower case, e.g. `what`, `where`, `identified by`, `not`, `ready when`, `kind`) to its text. */
|
|
10
|
-
fields: Record<string, string>;
|
|
11
|
-
/** `- Reference <profile-id>: <path>` bullets, keyed by what is written after `Reference`. */
|
|
12
|
-
references: Record<string, string>;
|
|
13
|
-
/** The card still has an open `🚫 TODO [blocking]` block: the target is not settled and no check may be built on it. */
|
|
14
|
-
blocking: boolean;
|
|
15
|
-
}
|
|
7
|
+
export type TargetCard = CaseTargetCard;
|
|
16
8
|
export declare const TARGET_REF: RegExp;
|
|
17
|
-
export declare const REQUIRED_FIELDS: readonly [
|
|
18
|
-
export declare
|
|
9
|
+
export declare const REQUIRED_FIELDS: readonly ["what", "where", "identified by"];
|
|
10
|
+
export declare const parseTargets: (text: string) => TargetCard[];
|
|
19
11
|
export declare const readCaseText: (id: string) => string;
|
|
20
12
|
/** The target ids that a text mentions as `[T1]`. */
|
|
21
13
|
export declare const targetRefsIn: (text: string) => string[];
|
package/dist/lib/targets.js
CHANGED
|
@@ -1,35 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { caseSectionText, parseTargetCards, TARGET_REQUIRED_FIELDS } from './case-doc.js';
|
|
3
4
|
import { CASES_DIR } from './paths.js';
|
|
4
5
|
export const TARGET_REF = /\[(T\d+)\]/g;
|
|
5
|
-
export const REQUIRED_FIELDS =
|
|
6
|
-
const
|
|
7
|
-
export function parseTargets(text) {
|
|
8
|
-
const body = section(text, 'Targets');
|
|
9
|
-
const cards = [];
|
|
10
|
-
const parts = body.split(/^### /m).slice(1);
|
|
11
|
-
for (const part of parts) {
|
|
12
|
-
const [heading, ...lines] = part.split('\n');
|
|
13
|
-
const head = /^(T\d+)\b[\s:.-]*(.*)$/.exec(heading.trim());
|
|
14
|
-
if (!head)
|
|
15
|
-
continue;
|
|
16
|
-
const card = { id: head[1], name: head[2], fields: {}, references: {}, blocking: false };
|
|
17
|
-
for (const line of lines) {
|
|
18
|
-
if (/^\s*-\s*🚫 TODO \[blocking\]/.test(line))
|
|
19
|
-
card.blocking = true;
|
|
20
|
-
const reference = /^-\s+Reference\s+([^:]+):\s*(.*)$/.exec(line);
|
|
21
|
-
if (reference) {
|
|
22
|
-
card.references[reference[1].trim()] = reference[2].trim();
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
const field = /^-\s+([A-Za-z][A-Za-z ]*?):\s*(.*)$/.exec(line);
|
|
26
|
-
if (field)
|
|
27
|
-
card.fields[field[1].trim().toLowerCase()] = field[2].trim();
|
|
28
|
-
}
|
|
29
|
-
cards.push(card);
|
|
30
|
-
}
|
|
31
|
-
return cards;
|
|
32
|
-
}
|
|
6
|
+
export const REQUIRED_FIELDS = TARGET_REQUIRED_FIELDS;
|
|
7
|
+
export const parseTargets = (text) => parseTargetCards(text);
|
|
33
8
|
export const readCaseText = (id) => fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8');
|
|
34
9
|
/** The target ids that a text mentions as `[T1]`. */
|
|
35
10
|
export const targetRefsIn = (text) => [...new Set([...text.matchAll(TARGET_REF)].map((match) => match[1]))];
|
|
@@ -38,7 +13,7 @@ export const NEEDS_TARGET = /\b(logo|image|icon|screenshot|snapshot|pdf|download
|
|
|
38
13
|
export function checksNeedingTarget(text) {
|
|
39
14
|
const found = [];
|
|
40
15
|
for (const name of ['Expected', 'Visual checks']) {
|
|
41
|
-
for (const line of
|
|
16
|
+
for (const line of caseSectionText(text, name).split('\n')) {
|
|
42
17
|
// A bullet that is only a question or an answer is part of a TODO block, not a check.
|
|
43
18
|
if (!/^\s*-\s/.test(line) || /TODO|Answer:/.test(line))
|
|
44
19
|
continue;
|
package/dist/lib/values.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
|
+
import { caseSectionText } from './case-doc.js';
|
|
4
5
|
import { CASES_DIR } from './paths.js';
|
|
5
6
|
import { loadProfiles } from './profiles.js';
|
|
6
|
-
const section = (text, name) => new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? '';
|
|
7
7
|
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
8
|
const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
|
|
9
9
|
/** Parses and validates the `## Values` block. Throws an Error that starts with `label` and says what to fix. */
|
|
10
10
|
export function parseCaseValues(text, label = 'case document') {
|
|
11
|
-
const body =
|
|
11
|
+
const body = caseSectionText(text, 'Values').trim();
|
|
12
12
|
if (body === '')
|
|
13
13
|
return {};
|
|
14
14
|
const block = /```ya?ml\r?\n([\s\S]*?)```/i.exec(body);
|
package/dist/scripts/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { COPIED_SCRIPTS } from '../lib/build-artifacts.js';
|
|
4
5
|
import { commandScript } from '../lib/node-cli.js';
|
|
5
6
|
import { FRAMEWORK_ROOT } from '../lib/paths.js';
|
|
6
7
|
// Compiles the framework to `dist/`: JavaScript and type declarations that Node and Playwright can load from a
|
|
@@ -12,7 +13,7 @@ const result = spawnSync(process.execPath, [tsc, '-p', path.join(FRAMEWORK_ROOT,
|
|
|
12
13
|
});
|
|
13
14
|
if ((result.status ?? 1) !== 0)
|
|
14
15
|
process.exit(result.status ?? 1);
|
|
15
|
-
for (const file of
|
|
16
|
+
for (const file of COPIED_SCRIPTS) {
|
|
16
17
|
const from = path.join(FRAMEWORK_ROOT, 'scripts', file);
|
|
17
18
|
const to = path.join(FRAMEWORK_ROOT, 'dist', 'scripts', file);
|
|
18
19
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
@@ -1,18 +1,82 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { CASES_DIR } from '../lib/paths.js';
|
|
3
3
|
import { listCaseFileIds, readCaseMeta } from '../lib/cases.js';
|
|
4
|
+
import { parseCaseDocument } from '../lib/case-doc.js';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { ROOT } from '../lib/paths.js';
|
|
6
7
|
import { checksNeedingTarget, parseTargets, readCaseText, REQUIRED_FIELDS, targetRefsIn } from '../lib/targets.js';
|
|
7
8
|
import { parseCaseValues } from '../lib/values.js';
|
|
8
|
-
/** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that "## Inputs"
|
|
9
|
+
/** Step numbers of the lines marked `[input]` or `[writes]` in "## Steps" (a person does them), and the numbers that have a `- Recipe:` line under them (or an old "## Inputs" line). */
|
|
9
10
|
function inputSteps(id) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
|
|
12
|
+
const marked = doc.steps
|
|
13
|
+
.filter((step) => step.tags.includes('input') || step.tags.includes('writes'))
|
|
14
|
+
.map((step) => step.number);
|
|
15
|
+
const recorded = [
|
|
16
|
+
...doc.steps.flatMap((step) => step.recipes.map(() => step.number)),
|
|
17
|
+
...doc.inputs.map((input) => input.step).filter((step) => step !== null),
|
|
18
|
+
];
|
|
14
19
|
return { marked, recorded };
|
|
15
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Warnings about the question blocks and the recipe lines of a case: a block nobody can answer, an answer that is not
|
|
23
|
+
* the last line (so the agent cannot tell what it belongs to), a question with no text to keep, a nesting that Markdown
|
|
24
|
+
* will not render inside its step, and a recipe under a step that is not tagged (all `warn`: old cases keep working).
|
|
25
|
+
*/
|
|
26
|
+
function questionProblems(id) {
|
|
27
|
+
const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
|
|
28
|
+
const warnings = [];
|
|
29
|
+
for (const question of doc.questions) {
|
|
30
|
+
if (!question.hasAnswerLine) {
|
|
31
|
+
warnings.push(`the question on line ${question.line} has no "✍️ Answer:" line; add one so it can be answered in place`);
|
|
32
|
+
}
|
|
33
|
+
else if (question.answerLine !== question.lastLine) {
|
|
34
|
+
warnings.push(`the "✍️ Answer:" line of the question on line ${question.line} is not the last line of its block`);
|
|
35
|
+
}
|
|
36
|
+
if (!question.parent) {
|
|
37
|
+
warnings.push(`the question on line ${question.line} has no text above it: accepting it would delete a claim nobody sees`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const step = doc.steps.find((candidate) => candidate.line === question.parent?.line);
|
|
41
|
+
if (step && question.indent < 4) {
|
|
42
|
+
warnings.push(`the question on line ${question.line} is indented by ${question.indent} space(s) under step ${step.number}; 4 renders it inside the step`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const step of doc.steps) {
|
|
46
|
+
if (step.recipes.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
if (!step.tags.includes('input') && !step.tags.includes('writes')) {
|
|
49
|
+
warnings.push(`step ${step.number} has a "- Recipe:" line but is not tagged [input] or [writes]`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const input of doc.inputs) {
|
|
53
|
+
if (input.step === null)
|
|
54
|
+
continue;
|
|
55
|
+
const step = doc.steps.find((candidate) => candidate.number === input.step);
|
|
56
|
+
if (!step) {
|
|
57
|
+
warnings.push(`"## Inputs" line ${input.line} (old format) belongs to step ${input.step}, which this case does not have`);
|
|
58
|
+
}
|
|
59
|
+
else if (!step.tags.includes('input') && !step.tags.includes('writes')) {
|
|
60
|
+
warnings.push(`"## Inputs" line ${input.line} (old format) belongs to step ${input.step}, which is not tagged [input] or [writes]`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return warnings;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Warnings for the old layout. `## Data` and `## Inputs` are deprecated: the one-sentence summary belongs in
|
|
67
|
+
* `writes-note`, the cleanup in steps, and a person's recipe under its step. Cases keep working, so this never fails.
|
|
68
|
+
*/
|
|
69
|
+
function formatProblems(id) {
|
|
70
|
+
const doc = parseCaseDocument(fs.readFileSync(path.join(CASES_DIR, `${id}.md`), 'utf8'));
|
|
71
|
+
const warnings = [];
|
|
72
|
+
if (doc.sections.some((section) => section.name === 'Data')) {
|
|
73
|
+
warnings.push('has a "## Data" section, which is no longer part of the case format: move it into "writes-note", the delete steps or "## Notes"');
|
|
74
|
+
}
|
|
75
|
+
if (doc.inputs.length > 0) {
|
|
76
|
+
warnings.push('uses the old "## Inputs" section: move each line under its step as "- Recipe (phase): ..."');
|
|
77
|
+
}
|
|
78
|
+
return warnings;
|
|
79
|
+
}
|
|
16
80
|
/** Problems with the target cards (`## Targets`) of a case: `fail` stops, `warn` is what `/explore-case` still has to settle. */
|
|
17
81
|
function targetProblems(id) {
|
|
18
82
|
const text = readCaseText(id);
|
|
@@ -70,8 +134,12 @@ for (const id of ids) {
|
|
|
70
134
|
const { marked, recorded } = inputSteps(id);
|
|
71
135
|
const missing = marked.filter((step) => !recorded.includes(step));
|
|
72
136
|
if (missing.length > 0) {
|
|
73
|
-
console.log(`warn ${id}: step ${missing.join(', ')} is marked [input] or [writes] but has no
|
|
137
|
+
console.log(`warn ${id}: step ${missing.join(', ')} is marked [input] or [writes] but has no "- Recipe:" line under it (run /explore-case --attended)`);
|
|
74
138
|
}
|
|
139
|
+
for (const message of formatProblems(id))
|
|
140
|
+
console.log(`warn ${id}: ${message}`);
|
|
141
|
+
for (const message of questionProblems(id))
|
|
142
|
+
console.log(`warn ${id}: ${message}`);
|
|
75
143
|
}
|
|
76
144
|
catch (error) {
|
|
77
145
|
problems++;
|
package/dist/scripts/doctor.js
CHANGED
|
@@ -2,7 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
4
4
|
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
|
+
import { spawnTarget } from '../lib/command.js';
|
|
5
6
|
import { GENERATED, frameworkPath, frameworkVersion, renderTemplate } from './init.js';
|
|
7
|
+
import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
|
|
6
8
|
/**
|
|
7
9
|
* Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
|
|
8
10
|
* Errors exit with 1; warnings do not.
|
|
@@ -26,6 +28,45 @@ function nonNegotiableRules() {
|
|
|
26
28
|
}
|
|
27
29
|
return ids;
|
|
28
30
|
}
|
|
31
|
+
/** Every rule id the framework baseline defines (`F1`..`F10`), so a project cannot name one that does not exist. */
|
|
32
|
+
function frameworkRuleIds() {
|
|
33
|
+
const ids = new Set();
|
|
34
|
+
for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
|
|
35
|
+
const match = /^- \*\*(F\d+)\b/.exec(line.trim());
|
|
36
|
+
if (match)
|
|
37
|
+
ids.add(match[1]);
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The framework sources that `pnpm build` compiles into `dist/` and are older than it, or missing from it. Only the
|
|
43
|
+
* framework repository has those sources; an installed package ships `dist/` alone, so the list is empty there.
|
|
44
|
+
*/
|
|
45
|
+
function staleDistFiles() {
|
|
46
|
+
const stale = [];
|
|
47
|
+
const compare = (source) => {
|
|
48
|
+
const compiled = path.join(FRAMEWORK_ROOT, builtFile(source));
|
|
49
|
+
if (!fs.existsSync(compiled))
|
|
50
|
+
stale.push(`${builtFile(source)} is missing`);
|
|
51
|
+
else if (fs.statSync(path.join(FRAMEWORK_ROOT, source)).mtimeMs > fs.statSync(compiled).mtimeMs)
|
|
52
|
+
stale.push(source);
|
|
53
|
+
};
|
|
54
|
+
const walk = (dir) => {
|
|
55
|
+
if (!fs.existsSync(path.join(FRAMEWORK_ROOT, dir)))
|
|
56
|
+
return;
|
|
57
|
+
for (const entry of fs.readdirSync(path.join(FRAMEWORK_ROOT, dir), { withFileTypes: true })) {
|
|
58
|
+
const source = `${dir}/${entry.name}`;
|
|
59
|
+
if (entry.isDirectory())
|
|
60
|
+
walk(source);
|
|
61
|
+
// The plain `.mjs` tools are copied as they are; every other file is TypeScript and becomes JavaScript.
|
|
62
|
+
else if (source.endsWith('.ts') || COPIED_SCRIPTS.includes(entry.name))
|
|
63
|
+
compare(source);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
walk('lib');
|
|
67
|
+
walk('scripts');
|
|
68
|
+
return stale;
|
|
69
|
+
}
|
|
29
70
|
export async function main(args) {
|
|
30
71
|
if (args.includes('--help')) {
|
|
31
72
|
console.log('Usage: pnpm asrun doctor');
|
|
@@ -66,6 +107,14 @@ export async function main(args) {
|
|
|
66
107
|
}
|
|
67
108
|
}
|
|
68
109
|
const framework = recordedPath ?? actualPath;
|
|
110
|
+
// The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
|
|
111
|
+
// load the config from there. A build older than the sources therefore keeps runs on the previous code.
|
|
112
|
+
const stale = staleDistFiles();
|
|
113
|
+
if (stale.length > 0) {
|
|
114
|
+
const shown = stale.slice(0, 3).join(', ');
|
|
115
|
+
warnings.push(`dist/ is out of date (${shown}${stale.length > 3 ? `, and ${stale.length - 3} more` : ''}): run "pnpm build". ` +
|
|
116
|
+
'Workspaces load playwright.config.ts from dist, so they keep the previous configuration until then.');
|
|
117
|
+
}
|
|
69
118
|
// Profiles: they are the only source of environments, and a broken file stops every command.
|
|
70
119
|
try {
|
|
71
120
|
const profiles = loadProfiles();
|
|
@@ -124,7 +173,19 @@ export async function main(args) {
|
|
|
124
173
|
const skillsDir = path.join(ROOT, '.claude', 'skills');
|
|
125
174
|
const overrides = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((name) => name.endsWith('.md')) : [];
|
|
126
175
|
facts.push(`skills: ${SKILLS.join(', ')}${overrides.length > 0 ? ` (project overrides: ${overrides.map((name) => name.replace(/\.md$/, '')).join(', ')})` : ''}`);
|
|
127
|
-
|
|
176
|
+
for (const name of overrides) {
|
|
177
|
+
const text = read(path.join(skillsDir, name)) ?? '';
|
|
178
|
+
// An override refines a skill's procedure; redefining a safety rule is never an override.
|
|
179
|
+
if (/^\s*[-*]?\s*\*{0,2}F[1-6]\b/m.test(text)) {
|
|
180
|
+
errors.push(`.claude/skills/${name} redefines a safety rule (F1–F6); project rules never change those.`);
|
|
181
|
+
}
|
|
182
|
+
const frameworkSkill = read(path.join(SKILLS_DIR, name.replace(/\.md$/, ''), 'SKILL.md'));
|
|
183
|
+
if (frameworkSkill !== null && text.trim() === frameworkSkill.trim()) {
|
|
184
|
+
warnings.push(`.claude/skills/${name} is a copy of the framework skill; delete it, or it will never see an update.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Project rules: a project may narrow or replace a baseline rule, never one of F1–F6. Rows are
|
|
188
|
+
// `| <id> | <the project's rule> | <narrows|replaces> |`.
|
|
128
189
|
const agents = read(path.join(ROOT, 'AGENTS.md'));
|
|
129
190
|
if (agents === null) {
|
|
130
191
|
warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
|
|
@@ -134,11 +195,29 @@ export async function main(args) {
|
|
|
134
195
|
errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
|
|
135
196
|
}
|
|
136
197
|
const locked = nonNegotiableRules();
|
|
198
|
+
const known = frameworkRuleIds();
|
|
199
|
+
let fenced = false;
|
|
137
200
|
for (const line of agents.split('\n')) {
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
201
|
+
// A fenced block is documentation (an example table), not the project's rules.
|
|
202
|
+
if (line.trim().startsWith('```')) {
|
|
203
|
+
fenced = !fenced;
|
|
204
|
+
continue;
|
|
141
205
|
}
|
|
206
|
+
if (fenced)
|
|
207
|
+
continue;
|
|
208
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
209
|
+
if (!/^F\d+$/.test(cells[0]))
|
|
210
|
+
continue;
|
|
211
|
+
const id = cells[0];
|
|
212
|
+
const effect = (cells[2] ?? '').toLowerCase();
|
|
213
|
+
if (!known.has(id))
|
|
214
|
+
errors.push(`AGENTS.md names ${id}, which is not a rule of the framework baseline.`);
|
|
215
|
+
else if (locked.has(id))
|
|
216
|
+
errors.push(`AGENTS.md changes ${id}, which the framework marks [non-negotiable].`);
|
|
217
|
+
else if (effect === '')
|
|
218
|
+
warnings.push(`AGENTS.md rule ${id} has no Effect column: add "narrows" or "replaces" (a table written before 0.3.0 predates it).`);
|
|
219
|
+
else if (effect !== 'narrows' && effect !== 'replaces')
|
|
220
|
+
errors.push(`AGENTS.md rule ${id} has Effect "${cells[2]}"; use "narrows" or "replaces".`);
|
|
142
221
|
}
|
|
143
222
|
}
|
|
144
223
|
// The install and the commands the docs use.
|
|
@@ -168,6 +247,38 @@ export async function main(args) {
|
|
|
168
247
|
if (scripts !== null && !scripts[name])
|
|
169
248
|
warnings.push(`package.json has no "${name}" script: run "pnpm asrun init".`);
|
|
170
249
|
}
|
|
250
|
+
// The agent CLIs the AI commands start. Only those commands need one, so a missing one is a warning, not an error.
|
|
251
|
+
// Checked the way the web UI starts them, so a Windows `.cmd` shim is reported instead of failing later.
|
|
252
|
+
const tools = ['claude', 'opencode']
|
|
253
|
+
.map((name) => ({ name, target: spawnTarget(name) }))
|
|
254
|
+
.filter((tool) => tool.target !== null);
|
|
255
|
+
if (tools.length === 0) {
|
|
256
|
+
warnings.push('Neither "claude" (Claude Code) nor "opencode" was found on PATH: the AI commands (make cases, explore a case, generate a spec) cannot start.');
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
facts.push(`ai tools: ${tools
|
|
260
|
+
.map((tool) => `${tool.name} -> ${tool.target.path}${tool.target.throughShell ? ' (through cmd.exe)' : ''}`)
|
|
261
|
+
.join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
// The MCP servers the AI commands explore with: OpenCode reads only `opencode.jsonc`, Claude Code only `.mcp.json`.
|
|
264
|
+
for (const [file, tool] of [
|
|
265
|
+
['.mcp.json', 'Claude Code'],
|
|
266
|
+
['opencode.jsonc', 'OpenCode'],
|
|
267
|
+
]) {
|
|
268
|
+
const text = read(path.join(ROOT, file));
|
|
269
|
+
if (text === null) {
|
|
270
|
+
warnings.push(`${file} is missing: ${tool} has no "playwright-test" MCP server. Run "pnpm asrun init".`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!text.includes('playwright-test')) {
|
|
274
|
+
warnings.push(`${file} does not define the "playwright-test" MCP server: run "pnpm asrun init --force ${file}".`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const command = /"command"\s*:\s*(?:\[\s*)?"([^"]*playwright[^"]*)"/.exec(text)?.[1];
|
|
278
|
+
if (command !== undefined && !fs.existsSync(path.resolve(ROOT, command)) && !fs.existsSync(path.resolve(ROOT, `${command}.cmd`))) {
|
|
279
|
+
warnings.push(`${file} starts the MCP server with "${command}", which does not exist: run "pnpm asrun init --force ${file}".`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
171
282
|
const settings = read(path.join(ROOT, '.claude', 'settings.json'));
|
|
172
283
|
if (settings === null) {
|
|
173
284
|
warnings.push('.claude/settings.json is missing: run "pnpm asrun init" (Claude Code needs its sandbox exclusions for pnpm asrun).');
|
package/dist/scripts/explore.js
CHANGED
|
@@ -51,4 +51,25 @@ context.on('page', watchPage);
|
|
|
51
51
|
const page = await context.newPage();
|
|
52
52
|
await page.goto(new URL(pathname, profile.baseURL).href);
|
|
53
53
|
console.log(`Opened ${profileLabel(profile)}. Close the browser window to exit.`);
|
|
54
|
-
|
|
54
|
+
// Chromium keeps its process alive after the last window is closed, so waiting for `disconnected` alone
|
|
55
|
+
// never finishes. Closing the last window does close its pages, and that is the signal to stop.
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const done = () => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
resolve();
|
|
63
|
+
};
|
|
64
|
+
browser.on('disconnected', done);
|
|
65
|
+
context.on('close', done);
|
|
66
|
+
const watch = (open) => open.on('close', () => {
|
|
67
|
+
if (context.pages().length === 0)
|
|
68
|
+
done();
|
|
69
|
+
});
|
|
70
|
+
context.pages().forEach(watch);
|
|
71
|
+
context.on('page', watch);
|
|
72
|
+
});
|
|
73
|
+
// The window is gone, but its browser process can still be running: end it so no orphan process is left.
|
|
74
|
+
await browser.close().catch(() => { });
|
|
75
|
+
console.log('Browser closed.');
|