@asrunjs/asrun 0.2.0 → 0.5.2

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.
Files changed (94) hide show
  1. package/.claude/commands/explore-case.md +18 -15
  2. package/.claude/commands/gen-spec.md +16 -13
  3. package/.claude/commands/heal-spec.md +26 -23
  4. package/.claude/commands/make-case.md +11 -8
  5. package/.claude/skills/exploring-cases/SKILL.md +146 -132
  6. package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
  7. package/.claude/skills/exploring-cases/references/readiness.md +146 -137
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
  9. package/.claude/skills/generating-specs/SKILL.md +219 -207
  10. package/.claude/skills/generating-specs/references/conventions.md +148 -148
  11. package/.claude/skills/making-cases/SKILL.md +72 -64
  12. package/.claude/skills/making-cases/references/normalizing.md +39 -38
  13. package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
  14. package/AGENTS.md +159 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +512 -410
  17. package/dist/lib/build-artifacts.d.ts +8 -0
  18. package/dist/lib/build-artifacts.js +10 -0
  19. package/dist/lib/case-doc.d.ts +311 -0
  20. package/dist/lib/case-doc.js +1117 -0
  21. package/dist/lib/cases.d.ts +2 -15
  22. package/dist/lib/cases.js +5 -78
  23. package/dist/lib/command.d.ts +24 -0
  24. package/dist/lib/command.js +90 -0
  25. package/dist/lib/fixtures.js +2 -2
  26. package/dist/lib/login-card.d.ts +22 -0
  27. package/dist/lib/login-card.js +183 -0
  28. package/dist/lib/node-cli.d.ts +6 -2
  29. package/dist/lib/node-cli.js +16 -6
  30. package/dist/lib/profiles.d.ts +25 -1
  31. package/dist/lib/profiles.js +54 -1
  32. package/dist/lib/run-filters.js +10 -2
  33. package/dist/lib/session.d.ts +13 -1
  34. package/dist/lib/session.js +73 -7
  35. package/dist/lib/targets.d.ts +5 -13
  36. package/dist/lib/targets.js +4 -29
  37. package/dist/lib/values.js +2 -2
  38. package/dist/scripts/build.js +2 -1
  39. package/dist/scripts/check-cases.js +74 -6
  40. package/dist/scripts/doctor.js +115 -4
  41. package/dist/scripts/explore.js +22 -1
  42. package/dist/scripts/init.d.ts +7 -1
  43. package/dist/scripts/init.js +61 -13
  44. package/dist/scripts/lint-specs.js +4 -1
  45. package/dist/scripts/login.js +30 -2
  46. package/dist/ui/server/actions.d.ts +4 -0
  47. package/dist/ui/server/actions.js +25 -12
  48. package/dist/ui/server/ai-models.d.ts +3 -0
  49. package/dist/ui/server/ai-models.js +347 -0
  50. package/dist/ui/server/ai-stream.d.ts +34 -9
  51. package/dist/ui/server/ai-stream.js +283 -77
  52. package/dist/ui/server/app.js +74 -12
  53. package/dist/ui/server/jobs.d.ts +7 -1
  54. package/dist/ui/server/jobs.js +69 -15
  55. package/dist/ui/server/profiles.d.ts +20 -1
  56. package/dist/ui/server/profiles.js +214 -38
  57. package/dist/ui/shared/ai-commands.d.ts +16 -6
  58. package/dist/ui/shared/ai-commands.js +35 -12
  59. package/dist/ui/shared/types.d.ts +114 -0
  60. package/docs/agents/framework-dev.md +79 -0
  61. package/docs/theme-guide.html +922 -692
  62. package/package.json +12 -1
  63. package/templates/.claude/commands/explore-case.md +22 -21
  64. package/templates/.claude/commands/gen-spec.md +21 -20
  65. package/templates/.claude/commands/heal-spec.md +31 -30
  66. package/templates/.claude/commands/make-case.md +13 -12
  67. package/templates/.mcp.json +8 -8
  68. package/templates/AGENTS.md +64 -45
  69. package/templates/cases/_template.md +57 -63
  70. package/templates/opencode.jsonc +16 -11
  71. package/templates/profiles.yaml +42 -29
  72. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
  73. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
  74. package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
  75. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
  76. package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
  77. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
  78. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
  79. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
  80. package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
  81. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
  82. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  83. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  84. package/ui/dist/help/ai-tools.png +0 -0
  85. package/ui/dist/help/case-explore.png +0 -0
  86. package/ui/dist/help/case-generate.png +0 -0
  87. package/ui/dist/help/cases-memo.png +0 -0
  88. package/ui/dist/help/profile-form.png +0 -0
  89. package/ui/dist/help/profiles-login.png +0 -0
  90. package/ui/dist/help/reports.png +0 -0
  91. package/ui/dist/help/run-tests.png +0 -0
  92. package/ui/dist/index.html +22 -16
  93. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  94. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -1,18 +1,5 @@
1
- /** The front matter of a case document that the tools care about. The case document is the single source of truth. */
2
- export interface CaseMeta {
3
- id: string;
4
- /** Suites the case belongs to, e.g. `['smoke', 'regression']`. A case runs in every suite it lists. */
5
- suites: string[];
6
- /**
7
- * Environments in which the case may create or change data of the test accounts (and cleans it up), e.g.
8
- * `['staging']`. Empty = the case never writes. In any other environment the case is skipped, not failed.
9
- */
10
- writes: string[];
11
- /** What the case writes; required when `writes` is not empty. Shown to the person who confirms a production run. */
12
- writesNote: string | null;
13
- /** Environments the case may run in; `null` = all. */
14
- envs: string[] | null;
15
- }
1
+ import { type CaseMeta } from './case-doc.js';
2
+ export type { CaseMeta } from './case-doc.js';
16
3
  /** Does the case write data at all (in some environment)? */
17
4
  export declare const caseWrites: (meta: CaseMeta) => boolean;
18
5
  /** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
package/dist/lib/cases.js CHANGED
@@ -1,93 +1,20 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parse } from 'yaml';
3
+ import { parseCaseFrontMatter, validateCaseMeta } from './case-doc.js';
4
4
  import { listEnvs } from './profiles.js';
5
- import { NAME_PATTERN } from './case-id.js';
6
5
  import { CASES_DIR } from './paths.js';
7
- const frontMatterOf = (text) => {
8
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
9
- if (!match)
10
- return null;
11
- const value = parse(match[1]);
12
- return value && typeof value === 'object' ? value : null;
13
- };
14
- function nameList(value, key, label) {
15
- if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || !NAME_PATTERN.test(item))) {
16
- throw new Error(`${label}: "${key}" must be a list of lowercase words (letters, digits, dashes), e.g. "${key}: [smoke, regression]"`);
17
- }
18
- const duplicate = value.find((item, i) => value.indexOf(item) !== i);
19
- if (duplicate)
20
- throw new Error(`${label}: "${key}" lists "${duplicate}" twice`);
21
- return value;
22
- }
23
- /**
24
- * `writes` is a list of environment names. Omitted, `none` or `[]` mean the case never writes. The old values
25
- * `test-data` (and `staging` / `production` as a bare word) are refused with the fix, never guessed.
26
- */
27
- function parseWrites(value, label) {
28
- if (value === undefined || value === null || value === 'none')
29
- return [];
30
- if (value === 'test-data') {
31
- throw new Error(`${label}: "writes: test-data" is the old form. List the environments instead, e.g. "writes: [staging]"`);
32
- }
33
- if (Array.isArray(value) && value.length === 0)
34
- return [];
35
- if (!Array.isArray(value)) {
36
- throw new Error(`${label}: "writes" must be a list of environments, e.g. "writes: [staging]" (or omit it for read-only)`);
37
- }
38
- const writes = nameList(value, 'writes', label);
39
- const known = listEnvs();
40
- const unknown = writes.filter((env) => !known.includes(env));
41
- if (unknown.length > 0) {
42
- throw new Error(`${label}: unknown environment(s) in "writes": ${unknown.join(', ')}. Known: ${known.join(', ')}`);
43
- }
44
- return writes;
45
- }
46
6
  /** Does the case write data at all (in some environment)? */
47
7
  export const caseWrites = (meta) => meta.writes.length > 0;
48
8
  /** May the case write data in this environment? A case that writes elsewhere only is skipped here. */
49
9
  export const caseWritesIn = (meta, env) => meta.writes.includes(env);
50
10
  /** Parses and validates the front matter. Throws an Error that starts with `label` and says what to fix. */
51
11
  export function parseCaseMeta(id, text, label = `cases/${id}.md`) {
52
- const front = frontMatterOf(text);
12
+ const front = parseCaseFrontMatter(text);
53
13
  if (!front)
54
14
  throw new Error(`${label}: the file must start with a front matter block (--- ... ---)`);
55
- if (front.suites !== undefined && front.priority !== undefined) {
56
- throw new Error(`${label}: use "suites: [...]" only; "priority" is the old name of the same setting`);
57
- }
58
- // `priority: smoke` is the old form of `suites: [smoke]`.
59
- const suites = front.suites !== undefined
60
- ? nameList(front.suites, 'suites', label)
61
- : front.priority !== undefined
62
- ? nameList([front.priority], 'priority', label)
63
- : (() => {
64
- throw new Error(`${label}: front matter needs "suites: [<suite>, ...]", e.g. "suites: [smoke, regression]"`);
65
- })();
66
- const note = front['writes-note'];
67
- const writes = parseWrites(front.writes, label);
68
- if (writes.length > 0 && (typeof note !== 'string' || note.trim() === '')) {
69
- throw new Error(`${label}: "writes: [${writes.join(', ')}]" needs a "writes-note" that says what the case creates or changes`);
70
- }
71
- let envs = null;
72
- if (front.envs !== undefined) {
73
- envs = nameList(front.envs, 'envs', label);
74
- const known = listEnvs();
75
- const unknown = envs.filter((env) => !known.includes(env));
76
- if (unknown.length > 0) {
77
- throw new Error(`${label}: unknown environment(s) in "envs": ${unknown.join(', ')}. Known: ${known.join(', ')}`);
78
- }
79
- }
80
- const unreachable = writes.filter((env) => envs !== null && !envs.includes(env));
81
- if (unreachable.length > 0) {
82
- throw new Error(`${label}: "writes" lists ${unreachable.join(', ')}, but "envs" does not run the case there`);
83
- }
84
- return {
85
- id,
86
- suites,
87
- writes,
88
- writesNote: typeof note === 'string' && note.trim() !== '' ? note.trim() : null,
89
- envs,
90
- };
15
+ if (front.problem)
16
+ throw new Error(`${label}: ${front.problem}`);
17
+ return validateCaseMeta(id, front.data, listEnvs(), label);
91
18
  }
92
19
  const caseFile = (caseId) => path.join(CASES_DIR, `${caseId}.md`);
93
20
  export const hasCaseFile = (caseId) => fs.existsSync(caseFile(caseId));
@@ -0,0 +1,24 @@
1
+ /**
2
+ * How a command on PATH is started: the executable itself, or, on Windows, a `.cmd`/`.bat` shim that only cmd.exe
3
+ * can run. A CLI installed globally with npm or pnpm is such a shim (it ends up as `opencode.cmd`), and `spawn`
4
+ * without a shell can never start it: the bare name is not found (ENOENT) and the shim itself is refused (EINVAL).
5
+ */
6
+ export interface SpawnTarget {
7
+ /** The file to spawn. */
8
+ file: string;
9
+ args: string[];
10
+ /** The file that was found on PATH, for messages. */
11
+ path: string;
12
+ /** True when `file` is cmd.exe and `args` carry the quoted command line. */
13
+ throughShell: boolean;
14
+ }
15
+ /**
16
+ * The file a command starts. A path (absolute or with a separator) is taken as it is; anything else is looked up
17
+ * on PATH, trying PATHEXT on Windows, so `opencode` finds `opencode.cmd`. Returns null when nothing matches.
18
+ */
19
+ export declare function findCommand(command: string): string | null;
20
+ /**
21
+ * What to spawn for a command, or null when the command is not installed. Only a Windows shim goes through a shell;
22
+ * everything else is spawned directly, as before.
23
+ */
24
+ export declare function spawnTarget(command: string, args?: string[]): SpawnTarget | null;
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** The extensions Windows would try when it looks a command up; PATHEXT normally lists them. */
4
+ const WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD'];
5
+ const pathEntries = () => (process.env.PATH ?? process.env.Path ?? '')
6
+ .split(path.delimiter)
7
+ .map((entry) => entry.trim().replace(/^"(.*)"$/, '$1'))
8
+ .filter((entry) => entry !== '');
9
+ const windowsExtensions = () => {
10
+ const listed = (process.env.PATHEXT ?? '').split(';').filter((entry) => entry !== '');
11
+ return listed.length > 0 ? listed : WINDOWS_EXTENSIONS;
12
+ };
13
+ /** A file that exists and, off Windows, may be executed. */
14
+ function executable(file) {
15
+ try {
16
+ if (!fs.statSync(file).isFile())
17
+ return false;
18
+ fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /**
26
+ * The file a command starts. A path (absolute or with a separator) is taken as it is; anything else is looked up
27
+ * on PATH, trying PATHEXT on Windows, so `opencode` finds `opencode.cmd`. Returns null when nothing matches.
28
+ */
29
+ export function findCommand(command) {
30
+ if (path.isAbsolute(command) || command.includes('/') || command.includes('\\')) {
31
+ return executable(command) ? command : null;
32
+ }
33
+ const given = path.extname(command).toLowerCase();
34
+ const extensions = process.platform === 'win32'
35
+ ? windowsExtensions().some((extension) => extension.toLowerCase() === given)
36
+ ? ['']
37
+ : windowsExtensions()
38
+ : [''];
39
+ for (const dir of pathEntries()) {
40
+ for (const extension of extensions) {
41
+ const candidate = path.join(dir, command + extension);
42
+ if (executable(candidate))
43
+ return candidate;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /** MSVC quoting of one argument: a backslash is literal unless it comes right before a quote. */
49
+ function quoteWindows(value) {
50
+ let quoted = '"';
51
+ let slashes = 0;
52
+ for (const char of value) {
53
+ if (char === '\\') {
54
+ slashes += 1;
55
+ }
56
+ else if (char === '"') {
57
+ quoted += `${'\\'.repeat(slashes * 2 + 1)}"`;
58
+ slashes = 0;
59
+ }
60
+ else {
61
+ quoted += '\\'.repeat(slashes) + char;
62
+ slashes = 0;
63
+ }
64
+ }
65
+ return `${quoted}${'\\'.repeat(slashes * 2)}"`;
66
+ }
67
+ /** A `.cmd`/`.bat` shim, started through cmd.exe the way npm's own shims are: one command line, every part quoted. */
68
+ function shimTarget(file, args) {
69
+ // cmd.exe takes a single line, so a line break inside an argument would end the command.
70
+ const line = [file, ...args].map((value) => quoteWindows(value.replace(/[\r\n]+/g, ' '))).join(' ');
71
+ // `/s` strips exactly one pair of outer quotes, so the whole line is wrapped once more.
72
+ return {
73
+ file: process.env.ComSpec ?? 'cmd.exe',
74
+ args: ['/d', '/s', '/c', `"${line}"`],
75
+ path: file,
76
+ throughShell: true,
77
+ };
78
+ }
79
+ /**
80
+ * What to spawn for a command, or null when the command is not installed. Only a Windows shim goes through a shell;
81
+ * everything else is spawned directly, as before.
82
+ */
83
+ export function spawnTarget(command, args = []) {
84
+ const found = findCommand(command);
85
+ if (found === null)
86
+ return null;
87
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(found))
88
+ return shimTarget(found, args);
89
+ return { file: found, args, path: found, throughShell: false };
90
+ }
@@ -5,7 +5,7 @@ import { acquireAccountLock } from './account-lock.js';
5
5
  import { ledgerFile, TEST_DATA_PREFIX, writeLedger } from './records.js';
6
6
  import { caseIdFromFile } from './run-summary.js';
7
7
  import { ALLOW_WRITES_ENV, isProtectedEnv } from './write-gate.js';
8
- import { checkSession, isLoginUrl, parseNotLoggedIn, sessionProblemMessage, signinCommand, } from './session.js';
8
+ import { checkSession, isLoggedOutUrl, parseNotLoggedIn, sessionProblemMessage, signinCommand, } from './session.js';
9
9
  export const test = base.extend({
10
10
  profile: [
11
11
  async ({}, use, workerInfo) => {
@@ -125,7 +125,7 @@ export const test = base.extend({
125
125
  if (!mustLogin || testInfo.status === testInfo.expectedStatus) {
126
126
  return;
127
127
  }
128
- if (page.isClosed() || !isLoginUrl(page.url())) {
128
+ if (page.isClosed() || !isLoggedOutUrl(page.url(), profile.sessionCheck)) {
129
129
  return;
130
130
  }
131
131
  const description = `The session of profile "${profile.id}" in ${profile.env} expired during the run. Log in again: ${signinCommand(profile)}`;
@@ -0,0 +1,22 @@
1
+ import type { BrowserContext, Page } from '@playwright/test';
2
+ /**
3
+ * The card the person sees in the sign-in window: it tells them what to do and saves the session when they press Done,
4
+ * so nobody has to switch back to the UI and press the button on the job.
5
+ *
6
+ * The card is painted by an init script, so it is there again after every navigation the login makes (and in a popup
7
+ * the site opens). It is the small variant of the attended handoff card
8
+ * (`<framework>/.claude/skills/exploring-cases/references/handoff-card.js`): no copy chips, no finish condition, and
9
+ * it lives in its own shadow root so the site's CSS cannot change it. Styles go through a constructed stylesheet,
10
+ * because a site with a strict CSP blocks an inline `<style>` element.
11
+ */
12
+ /** How the wait for the person ended. */
13
+ export type LoginCardOutcome = 'done' | 'cancel' | 'closed' | 'stopped';
14
+ /** Adds the card to every page of the context, now and on every navigation after this call. */
15
+ export declare function showLoginCard(context: BrowserContext, message: string): void;
16
+ /** What the card says once the session is saved, before the window closes. */
17
+ export declare function showLoginCardSaved(context: BrowserContext, text: string): Promise<void>;
18
+ /**
19
+ * Waits until the person presses Done or Cancel on the card, or closes the window. `signal` lets the caller stop the
20
+ * wait when something else finished it first (the Enter key on the command line, or the button in the UI).
21
+ */
22
+ export declare function waitForLoginCard(context: BrowserContext, page: Page, signal?: AbortSignal): Promise<LoginCardOutcome>;
@@ -0,0 +1,183 @@
1
+ const FLAG = '__asrunLoginCard';
2
+ const FINISH = '__asrunLoginCardFinish';
3
+ /** Adds the card to every page of the context, now and on every navigation after this call. */
4
+ export function showLoginCard(context, message) {
5
+ void context.addInitScript({ content: loginCardSource(message) }).catch(() => { });
6
+ }
7
+ /** What the card says once the session is saved, before the window closes. */
8
+ export async function showLoginCardSaved(context, text) {
9
+ const call = `window.${FINISH} && window.${FINISH}(${JSON.stringify(text)})`;
10
+ await Promise.all(context.pages().map((page) => page.evaluate(call).catch(() => null)));
11
+ }
12
+ /**
13
+ * Waits until the person presses Done or Cancel on the card, or closes the window. `signal` lets the caller stop the
14
+ * wait when something else finished it first (the Enter key on the command line, or the button in the UI).
15
+ */
16
+ export async function waitForLoginCard(context, page, signal) {
17
+ const read = `window.${FLAG} ?? null`;
18
+ for (;;) {
19
+ for (const open of context.pages()) {
20
+ const flag = await open.evaluate(read).catch(() => null);
21
+ if (flag === 'done' || flag === 'cancel')
22
+ return flag;
23
+ }
24
+ if (signal?.aborted)
25
+ return 'stopped';
26
+ if (page.isClosed() || context.pages().length === 0)
27
+ return 'closed';
28
+ await new Promise((resolve) => setTimeout(resolve, 400));
29
+ }
30
+ }
31
+ /** The whole card as one script, with the message baked in: an init script cannot close over anything. */
32
+ function loginCardSource(message) {
33
+ return `(() => {
34
+ const MESSAGE = ${JSON.stringify(message)};
35
+ const FLAG = '${FLAG}';
36
+ const CLICK_KEY = '__ASRUN_login_card_click';
37
+ const POS_KEY = '__ASRUN_login_card_pos';
38
+ const ID = '__ASRUN_login_card';
39
+ try {
40
+ const early = sessionStorage.getItem(CLICK_KEY);
41
+ if (early) window[FLAG] = early;
42
+ } catch (error) {}
43
+
44
+ const paint = () => {
45
+ // One card, in the page the person logs in on: never in an iframe of the site.
46
+ if (window.top !== window.self) return true;
47
+ if (!document.body) return false;
48
+ if (document.getElementById(ID)) return true;
49
+
50
+ const state = {minimized: false, left: null, top: null};
51
+ try { Object.assign(state, JSON.parse(sessionStorage.getItem(POS_KEY) || '{}')); } catch (error) {}
52
+ const save = () => { try { sessionStorage.setItem(POS_KEY, JSON.stringify(state)); } catch (error) {} };
53
+
54
+ const svg = (body) =>
55
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" ' +
56
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + '</svg>';
57
+ const ICON = {
58
+ move: svg('<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/>' +
59
+ '<polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/>'),
60
+ up: svg('<polyline points="18 15 12 9 6 15"/>'),
61
+ down: svg('<polyline points="6 9 12 15 18 9"/>'),
62
+ };
63
+ const css = [
64
+ '.card{width:300px;max-width:calc(100vw - 32px);background:#1e3340;color:#fff;font:13px/1.4 system-ui,sans-serif;' +
65
+ 'border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.35);overflow:hidden}',
66
+ '.card.min{width:auto}.card.ok{background:#15803d}',
67
+ '.card.ok .body,.card.ok .mini-done{display:none}',
68
+ '.bar{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 12px;background:#b45309;cursor:grab;' +
69
+ 'user-select:none;touch-action:none}',
70
+ '.ok .bar{background:#166534;align-items:flex-start;padding-top:9px}',
71
+ '.bar b{flex:1;min-width:0;font-size:12px;letter-spacing:.04em;white-space:nowrap}',
72
+ '.ok .bar b{white-space:normal;line-height:1.35}',
73
+ '.grip{display:inline-flex;opacity:.8}',
74
+ 'button{all:unset;box-sizing:border-box;cursor:pointer;display:inline-flex;align-items:center;' +
75
+ 'justify-content:center;gap:4px;padding:3px 8px;border-radius:4px;background:rgba(255,255,255,.18);' +
76
+ 'color:#fff;font:600 12px system-ui,sans-serif}',
77
+ 'button:hover{background:rgba(255,255,255,.3)}',
78
+ '.body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:8px}.min .body{display:none}',
79
+ '.mini-done{display:none}.min .mini-done{display:inline-flex}',
80
+ '.row{display:flex;gap:8px}.row .primary{background:#15803d}.row .primary:hover{background:#16a34a}',
81
+ '.row .primary{flex:1}',
82
+ ].join('');
83
+
84
+ const host = document.createElement('div');
85
+ host.id = ID;
86
+ host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;left:16px;bottom:16px';
87
+ const root = host.attachShadow({mode: 'open'});
88
+ // A constructed stylesheet is not an inline style, so a site with a strict CSP cannot block it.
89
+ try {
90
+ const sheet = new CSSStyleSheet();
91
+ sheet.replaceSync(css);
92
+ root.adoptedStyleSheets = [sheet];
93
+ } catch (error) {
94
+ const style = document.createElement('style');
95
+ style.textContent = css;
96
+ root.appendChild(style);
97
+ }
98
+ const holder = document.createElement('div');
99
+ holder.innerHTML =
100
+ '<div class="card" id="card"><div class="bar" id="bar" title="Drag to move">' +
101
+ '<span class="grip">' + ICON.move + '</span><b id="title">YOUR TURN: log in</b>' +
102
+ '<button class="mini-done" data-a="done">Done</button>' +
103
+ '<button id="toggle" title="Minimize or expand" aria-label="Minimize or expand"></button></div>' +
104
+ '<div class="body"><div id="msg"></div><div class="row">' +
105
+ '<button class="primary" data-a="done">Done, save the session</button>' +
106
+ '<button data-a="cancel">Cancel</button></div></div></div>';
107
+
108
+ const card = holder.firstElementChild;
109
+ const bar = card.querySelector('#bar');
110
+ const toggle = card.querySelector('#toggle');
111
+ const message = card.querySelector('#msg');
112
+ message.textContent = MESSAGE;
113
+ root.appendChild(card);
114
+
115
+ const apply = () => {
116
+ card.classList.toggle('min', state.minimized);
117
+ toggle.innerHTML = state.minimized ? ICON.up : ICON.down;
118
+ };
119
+ const place = () => {
120
+ if (state.left === null) return;
121
+ const box = host.getBoundingClientRect();
122
+ const left = Math.max(0, Math.min(innerWidth - Math.min(box.width, 80), state.left));
123
+ const top = Math.max(0, Math.min(innerHeight - 36, state.top));
124
+ host.style.left = left + 'px';
125
+ host.style.top = top + 'px';
126
+ host.style.bottom = 'auto';
127
+ };
128
+
129
+ // The card must never take the keyboard focus away from the site's login form.
130
+ root.addEventListener('mousedown', (event) => {
131
+ if (event.target.closest('button')) event.preventDefault();
132
+ });
133
+ root.addEventListener('click', (event) => {
134
+ const button = event.target.closest('[data-a]');
135
+ if (!button) return;
136
+ const value = button.dataset.a;
137
+ window[FLAG] = value;
138
+ try { sessionStorage.setItem(CLICK_KEY, value); } catch (error) {}
139
+ });
140
+ toggle.addEventListener('click', () => {
141
+ state.minimized = !state.minimized;
142
+ save();
143
+ apply();
144
+ place();
145
+ });
146
+
147
+ let drag = null;
148
+ bar.addEventListener('pointerdown', (event) => {
149
+ if (event.target.closest('button')) return;
150
+ const box = host.getBoundingClientRect();
151
+ drag = {dx: event.clientX - box.left, dy: event.clientY - box.top};
152
+ bar.setPointerCapture(event.pointerId);
153
+ bar.style.cursor = 'grabbing';
154
+ });
155
+ bar.addEventListener('pointermove', (event) => {
156
+ if (!drag) return;
157
+ state.left = event.clientX - drag.dx;
158
+ state.top = event.clientY - drag.dy;
159
+ place();
160
+ });
161
+ bar.addEventListener('pointerup', () => {
162
+ drag = null;
163
+ bar.style.cursor = 'grab';
164
+ save();
165
+ });
166
+
167
+ window[${JSON.stringify(FINISH)}] = (text) => {
168
+ card.classList.remove('min');
169
+ card.classList.add('ok');
170
+ toggle.style.display = 'none';
171
+ bar.querySelector('b').textContent = text;
172
+ message.textContent = '';
173
+ };
174
+
175
+ apply();
176
+ document.documentElement.appendChild(host);
177
+ place();
178
+ return true;
179
+ };
180
+
181
+ if (!paint()) document.addEventListener('DOMContentLoaded', paint, {once: true});
182
+ })();`;
183
+ }
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Arguments that run one of the framework's own scripts (for example `login.ts`): the compiled file when the package
3
- * is built, the TypeScript source in the framework repository.
2
+ * Arguments that run one of the framework's own scripts (for example `login.ts`): the TypeScript source in the
3
+ * framework repository (tsx is installed there), the compiled file in a workspace, which only ships `dist/`.
4
+ *
5
+ * The source is tried first on purpose. A `dist/` left over from an earlier `pnpm build` must never win over a
6
+ * source file that was edited since: `pnpm asrun` runs `scripts/run.ts` through tsx, so the jobs the web UI starts
7
+ * have to run the same code, not the previous build.
4
8
  */
5
9
  export declare function scriptArgs(name: string): string[];
6
10
  /**
@@ -2,14 +2,24 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { FRAMEWORK_ROOT, packageDir } from './paths.js';
4
4
  /**
5
- * Arguments that run one of the framework's own scripts (for example `login.ts`): the compiled file when the package
6
- * is built, the TypeScript source in the framework repository.
5
+ * Arguments that run one of the framework's own scripts (for example `login.ts`): the TypeScript source in the
6
+ * framework repository (tsx is installed there), the compiled file in a workspace, which only ships `dist/`.
7
+ *
8
+ * The source is tried first on purpose. A `dist/` left over from an earlier `pnpm build` must never win over a
9
+ * source file that was edited since: `pnpm asrun` runs `scripts/run.ts` through tsx, so the jobs the web UI starts
10
+ * have to run the same code, not the previous build.
7
11
  */
8
12
  export function scriptArgs(name) {
9
- const compiled = path.join(FRAMEWORK_ROOT, 'dist', 'scripts', name.replace(/\.ts$/, '.js'));
10
- if (fs.existsSync(compiled))
11
- return [compiled];
12
- return [commandScript('tsx'), path.join(FRAMEWORK_ROOT, 'scripts', name)];
13
+ const source = path.join(FRAMEWORK_ROOT, 'scripts', name);
14
+ if (fs.existsSync(source)) {
15
+ try {
16
+ return [commandScript('tsx'), source];
17
+ }
18
+ catch {
19
+ // No tsx: an installed package ships `dist/` only, so the compiled file below is the one to run.
20
+ }
21
+ }
22
+ return [path.join(FRAMEWORK_ROOT, 'dist', 'scripts', name.replace(/\.ts$/, '.js'))];
13
23
  }
14
24
  /**
15
25
  * The JavaScript entry point of a package's command-line tool. The shims in `node_modules/.bin` are POSIX shell
@@ -1,3 +1,22 @@
1
+ export declare const DEFAULT_SESSION_CHECK_PATH = "/account/";
2
+ /** The page "Visit" opens by default: the site root. */
3
+ export declare const DEFAULT_START_PATH = "";
4
+ /**
5
+ * How to tell "signed in" from "signed out" on one site. A site can say it in different ways: a redirect to
6
+ * a login page (server or client side), a 401/403, a login form on the same address, or a "you must sign in"
7
+ * message. Every profile names the signal it has; the defaults cover the most common one, a redirect to `/login`.
8
+ */
9
+ export interface SessionCheck {
10
+ /** The address a visitor without a valid session is sent to: a path on the site, or a full URL (single sign-on). */
11
+ loggedOutUrl: string;
12
+ /** A selector that is only on the page while signed in, e.g. the username in the top bar. The most reliable signal. */
13
+ loggedInSelector: string | null;
14
+ /** A selector that shows the visitor is not signed in, e.g. the password field of the login form. */
15
+ loggedOutSelector: string | null;
16
+ /** How long to wait for one of those signals before closing the page again. */
17
+ timeoutMs: number;
18
+ }
19
+ export declare const DEFAULT_SESSION_CHECK: SessionCheck;
1
20
  export declare const PRODUCTION = "production";
2
21
  export declare const STAGING = "staging";
3
22
  export declare const PROFILES_FILE: string;
@@ -17,8 +36,12 @@ export interface Profile {
17
36
  tags: string[];
18
37
  /** Tests may send real email in this environment. Never true in production. */
19
38
  email: boolean;
20
- /** A page that redirects to /login when the session is missing or expired. */
39
+ /** The page "Visit" opens after a login, e.g. `/app/home`; empty = the site root. */
40
+ startPath: string;
41
+ /** A page that only a signed-in visitor sees; the page "Verify" opens. */
21
42
  sessionCheckPath: string;
43
+ /** How that page shows a missing or expired login (see `SessionCheck`). */
44
+ sessionCheck: SessionCheck;
22
45
  }
23
46
  /** The Playwright project name of a profile: its id. */
24
47
  export declare const projectName: (profile: {
@@ -28,6 +51,7 @@ export declare const profileLabel: (profile: {
28
51
  id: string;
29
52
  env: string;
30
53
  }) => string;
54
+ export declare function validateSessionCheck(where: string, raw: unknown): SessionCheck;
31
55
  export declare function loadProfiles(file?: string): Profile[];
32
56
  /** The profiles of one environment (`env` omitted: all of them), production first. */
33
57
  export declare const listProfiles: (env?: string, profiles?: Profile[]) => Profile[];
@@ -3,7 +3,15 @@ import path from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { ROOT } from './paths.js';
5
5
  const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6
- const DEFAULT_SESSION_CHECK_PATH = '/account/';
6
+ export const DEFAULT_SESSION_CHECK_PATH = '/account/';
7
+ /** The page "Visit" opens by default: the site root. */
8
+ export const DEFAULT_START_PATH = '';
9
+ export const DEFAULT_SESSION_CHECK = {
10
+ loggedOutUrl: '/login',
11
+ loggedInSelector: null,
12
+ loggedOutSelector: null,
13
+ timeoutMs: 3000,
14
+ };
7
15
  export const PRODUCTION = 'production';
8
16
  export const STAGING = 'staging';
9
17
  export const PROFILES_FILE = path.join(ROOT, 'profiles.yaml');
@@ -34,6 +42,44 @@ function validateSiteAddress(where, value) {
34
42
  }
35
43
  return value.replace(/\/$/, '');
36
44
  }
45
+ export function validateSessionCheck(where, raw) {
46
+ if (raw === undefined || raw === null)
47
+ return DEFAULT_SESSION_CHECK;
48
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
49
+ throw new Error(`${where}: "sessionCheck" must be a mapping with "loggedOutUrl", "loggedInSelector", "loggedOutSelector" or "timeoutMs"`);
50
+ }
51
+ const map = raw;
52
+ const known = ['loggedOutUrl', 'loggedInSelector', 'loggedOutSelector', 'timeoutMs'];
53
+ const unknown = Object.keys(map).find((key) => !known.includes(key));
54
+ if (unknown) {
55
+ throw new Error(`${where}: "sessionCheck" has an unknown key "${unknown}". Known: ${known.join(', ')}`);
56
+ }
57
+ const loggedOutUrl = map.loggedOutUrl ?? DEFAULT_SESSION_CHECK.loggedOutUrl;
58
+ if (typeof loggedOutUrl !== 'string' ||
59
+ (!loggedOutUrl.startsWith('/') && !/^https?:\/\//i.test(loggedOutUrl)) ||
60
+ /\s/.test(loggedOutUrl)) {
61
+ throw new Error(`${where}: "sessionCheck.loggedOutUrl" must be a path starting with "/" (e.g. /login) or a full http(s) URL`);
62
+ }
63
+ const selector = (key) => {
64
+ const value = map[key];
65
+ if (value === undefined || value === null || value === '')
66
+ return null;
67
+ if (typeof value !== 'string') {
68
+ throw new Error(`${where}: "sessionCheck.${key}" must be a selector such as "input[name='username']"`);
69
+ }
70
+ return value;
71
+ };
72
+ const timeoutMs = map.timeoutMs ?? DEFAULT_SESSION_CHECK.timeoutMs;
73
+ if (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs < 500 || timeoutMs > 60000) {
74
+ throw new Error(`${where}: "sessionCheck.timeoutMs" must be a whole number of milliseconds between 500 and 60000`);
75
+ }
76
+ return {
77
+ loggedOutUrl,
78
+ loggedInSelector: selector('loggedInSelector'),
79
+ loggedOutSelector: selector('loggedOutSelector'),
80
+ timeoutMs,
81
+ };
82
+ }
37
83
  function validate(raw, index) {
38
84
  const label = `profiles.yaml: profile #${index + 1}`;
39
85
  for (const key of ['id', 'name', 'env']) {
@@ -68,6 +114,11 @@ function validate(raw, index) {
68
114
  if (typeof sessionCheckPath !== 'string' || !sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath)) {
69
115
  throw new Error(`${where}: "sessionCheckPath" must start with "/" and contain no spaces`);
70
116
  }
117
+ const startPath = raw.startPath ?? DEFAULT_START_PATH;
118
+ if (typeof startPath !== 'string' ||
119
+ (startPath !== '' && (!startPath.startsWith('/') || startPath.startsWith('//') || /\s/.test(startPath)))) {
120
+ throw new Error(`${where}: "startPath" must be empty (the site root) or a path on the site that starts with a single "/" and has no spaces`);
121
+ }
71
122
  const account = typeof raw.account === 'string' && raw.account.trim() !== '' ? raw.account.trim() : null;
72
123
  return {
73
124
  id: raw.id,
@@ -77,7 +128,9 @@ function validate(raw, index) {
77
128
  account,
78
129
  tags: validateTags(where, raw.tags),
79
130
  email: raw.email === true,
131
+ startPath,
80
132
  sessionCheckPath,
133
+ sessionCheck: validateSessionCheck(where, raw.sessionCheck),
81
134
  };
82
135
  }
83
136
  export function loadProfiles(file = PROFILES_FILE) {