janissary 0.5.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.
- package/ai/guidelines/architecture-principles.md +75 -0
- package/ai/guidelines/code-guidelines.md +17 -0
- package/ai/guidelines/conventional-commits.md +192 -0
- package/ai/guidelines/developer-documentation.md +124 -0
- package/ai/guidelines/documentation.md +80 -0
- package/ai/guidelines/human-writing-guidelines.md +42 -0
- package/ai/guidelines/imports-and-barrel-files.md +49 -0
- package/ai/guidelines/pull-request-automation.md +98 -0
- package/ai/guidelines/strategies-for-readable-summaries.md +27 -0
- package/ai/guidelines/user-documentation.md +177 -0
- package/ai/personas/algorithm.md +14 -0
- package/ai/personas/assistant.md +25 -0
- package/ai/personas/link-scout.md +16 -0
- package/ai/personas/researcher.md +12 -0
- package/ai/personas/security.md +12 -0
- package/ai/personas/summarizer.md +11 -0
- package/ai/tasks/build-a-feature.md +133 -0
- package/ai/tasks/fix-a-small-issue.md +138 -0
- package/ai/tasks/improve-codebase.md +187 -0
- package/ai/tasks/improve-modularity.md +188 -0
- package/ai/tasks/improve-namespacing.md +253 -0
- package/ai/tasks/improve-plan.md +121 -0
- package/ai/tasks/improve-security.md +144 -0
- package/ai/tasks/improve-style.md +147 -0
- package/ai/tasks/improve-test-coverage.md +178 -0
- package/ai/tasks/merge-change-to-master.md +144 -0
- package/ai/tasks/open-feature-pull-request.md +161 -0
- package/ai/tasks/plan-ready-features.md +154 -0
- package/ai/tasks/prepare-workspace.md +35 -0
- package/ai/tasks/quick-commit.md +82 -0
- package/ai/tasks/reduce-complexity.md +185 -0
- package/ai/tasks/remove-deadcode.md +206 -0
- package/ai/tasks/remove-duplication.md +202 -0
- package/ai/tasks/update-package.md +137 -0
- package/package.json +5 -3
- package/scripts/changed-files.mjs +22 -0
- package/scripts/check-diff.mjs +100 -0
- package/scripts/coverage-file.mjs +85 -0
- package/scripts/docs-screenshots/capture.mjs +82 -0
- package/scripts/docs-screenshots/fixtures/docs/api.md +3 -0
- package/scripts/docs-screenshots/fixtures/docs/guide.md +3 -0
- package/scripts/docs-screenshots/fixtures/page.html +31 -0
- package/scripts/docs-screenshots/fixtures/profiles/demo/editor.json +1 -0
- package/scripts/docs-screenshots/fixtures/profiles/demo/writer.json +1 -0
- package/scripts/docs-screenshots/fixtures/sample.md +26 -0
- package/scripts/docs-screenshots/fixtures/sample.png +0 -0
- package/scripts/docs-screenshots/fixtures/sample.ts +25 -0
- package/scripts/docs-screenshots/fixtures/src/app.ts +2 -0
- package/scripts/docs-screenshots/fixtures/src/tides.ts +2 -0
- package/scripts/docs-screenshots/janus.mjs +51 -0
- package/scripts/docs-screenshots/manifest.mjs +122 -0
- package/scripts/docs-screenshots/scratch.mjs +53 -0
- package/scripts/docs-screenshots.mjs +86 -0
- package/scripts/lint-files.mjs +47 -0
- package/scripts/postinstall.mjs +14 -0
- package/scripts/pr-check-changes.sh +15 -0
- package/scripts/pr-check-gate.sh +29 -0
- package/scripts/pr-check-mergeable.sh +29 -0
- package/scripts/pr-commit.sh +23 -0
- package/scripts/pr-create-branch.sh +20 -0
- package/scripts/pr-create-pr.sh +29 -0
- package/scripts/pr-merge.sh +22 -0
- package/scripts/pr-push-branch.sh +24 -0
- package/scripts/pr-rebase.sh +56 -0
- package/scripts/pr-resolve-remote.sh +9 -0
- package/scripts/pr-wait-checks.sh +41 -0
- package/scripts/publish.mjs +61 -0
- package/scripts/release.mjs +209 -0
- package/scripts/run.mjs +40 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fast diff-scoped checks: lint changed files, typecheck affected projects (incremental),
|
|
3
|
+
// run related tests. Runs concurrently and reports a summary.
|
|
4
|
+
//
|
|
5
|
+
// npm run check:diff # concurrent lint + tsc + test
|
|
6
|
+
// npm run check:diff -- --seq # sequential (fail-fast) for debugging
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { changedFiles } from './changed-files.mjs';
|
|
10
|
+
|
|
11
|
+
const LINTABLE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
|
|
12
|
+
const seq = process.argv.includes('--seq');
|
|
13
|
+
|
|
14
|
+
const files = changedFiles();
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
console.log('check:diff: no changes');
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Classify files to decide which checks to run
|
|
21
|
+
const hasLintable = files.some((f) => {
|
|
22
|
+
const base = f.split('/').pop() ?? f;
|
|
23
|
+
return !base.includes('.') || LINTABLE.test(f);
|
|
24
|
+
});
|
|
25
|
+
const touchesSrc = files.some((f) => f.startsWith('src/'));
|
|
26
|
+
const touchesWeb = files.some((f) => f.startsWith('web/'));
|
|
27
|
+
|
|
28
|
+
// Tools to run with timing
|
|
29
|
+
const tools = [];
|
|
30
|
+
|
|
31
|
+
if (hasLintable) {
|
|
32
|
+
tools.push({
|
|
33
|
+
name: 'lint',
|
|
34
|
+
run: () => execFileSync('node', ['scripts/lint-files.mjs'], { stdio: 'pipe', encoding: 'utf8' }),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (touchesSrc || touchesWeb) {
|
|
39
|
+
tools.push({
|
|
40
|
+
name: 'tsc',
|
|
41
|
+
run: () => execFileSync('npm', ['run', 'typecheck:diff'], { stdio: 'pipe', encoding: 'utf8' }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (touchesSrc) {
|
|
46
|
+
tools.push({
|
|
47
|
+
name: 'test:server',
|
|
48
|
+
run: () => execFileSync('npm', ['run', 'test:diff:server'], { stdio: 'pipe', encoding: 'utf8' }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (touchesWeb) {
|
|
53
|
+
tools.push({
|
|
54
|
+
name: 'test:web',
|
|
55
|
+
run: () => execFileSync('npm', ['run', 'test:diff:web'], { stdio: 'pipe', encoding: 'utf8' }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Run tools concurrently or sequentially
|
|
60
|
+
const results = [];
|
|
61
|
+
const startTime = Date.now();
|
|
62
|
+
|
|
63
|
+
if (seq) {
|
|
64
|
+
for (const tool of tools) {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
let status = 'pass';
|
|
67
|
+
try {
|
|
68
|
+
tool.run();
|
|
69
|
+
} catch {
|
|
70
|
+
status = 'fail';
|
|
71
|
+
}
|
|
72
|
+
results.push({ ...tool, status, time: Date.now() - start });
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const promises = tools.map(
|
|
76
|
+
(tool) =>
|
|
77
|
+
new Promise((resolve) => {
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
let status = 'pass';
|
|
80
|
+
try {
|
|
81
|
+
tool.run();
|
|
82
|
+
} catch {
|
|
83
|
+
status = 'fail';
|
|
84
|
+
}
|
|
85
|
+
resolve({ ...tool, status, time: Date.now() - start });
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
results.push(...(await Promise.all(promises)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Print summary
|
|
92
|
+
const summary = results
|
|
93
|
+
.map((r) => `${r.name} ${r.status === 'pass' ? '✓' : '✗'} (${r.time}ms)`)
|
|
94
|
+
.join(' ');
|
|
95
|
+
console.log(`\n${summary}`);
|
|
96
|
+
console.log(`total: ${Date.now() - startTime}ms`);
|
|
97
|
+
|
|
98
|
+
// Exit non-zero if any failed
|
|
99
|
+
const anyFailed = results.some((r) => r.status === 'fail');
|
|
100
|
+
process.exit(anyFailed ? 1 : 0);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Print per-file coverage detail from coverage/coverage-final.json for one file, without
|
|
3
|
+
// building a one-off `node -e` snippet each time. Used by ai/tasks/improve-test-coverage.md's Step 6
|
|
4
|
+
// to precisely confirm a target file's coverage moved, since the printed summary table rounds
|
|
5
|
+
// percentages and truncates long paths.
|
|
6
|
+
//
|
|
7
|
+
// Usage: ./scripts/run.mjs coverage-file <path-substring>
|
|
8
|
+
// Requires `npm run coverage` to have already run (reads coverage/coverage-final.json).
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const substring = process.argv[2];
|
|
14
|
+
if (!substring) {
|
|
15
|
+
console.error('Usage: coverage-file <path-substring>');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const coveragePath = path.resolve('coverage', 'coverage-final.json');
|
|
20
|
+
let data;
|
|
21
|
+
try {
|
|
22
|
+
data = JSON.parse(readFileSync(coveragePath, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
console.error(`Could not read ${coveragePath} — run "npm run coverage" first.`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const matches = Object.keys(data).filter((key) => key.includes(substring));
|
|
29
|
+
if (matches.length === 0) {
|
|
30
|
+
console.error(`No file in coverage-final.json matches "${substring}".`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (matches.length > 1) {
|
|
34
|
+
console.error(`"${substring}" matches multiple files — be more specific:\n${matches.map((m) => ` ${m}`).join('\n')}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const file = data[matches[0]];
|
|
39
|
+
|
|
40
|
+
function summarize(map, hits) {
|
|
41
|
+
const total = Object.keys(map).length;
|
|
42
|
+
const covered = Object.values(hits).filter((count) => count > 0).length;
|
|
43
|
+
const pct = total === 0 ? 100 : Math.round((covered / total) * 1000) / 10;
|
|
44
|
+
return { total, covered, pct };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Branches are arrays of hit counts (one per location); a branch entry is "covered" only when
|
|
48
|
+
// every location in it was hit at least once, matching Istanbul's own branch-coverage definition.
|
|
49
|
+
function summarizeBranches(branchMap, b) {
|
|
50
|
+
const entries = Object.entries(branchMap);
|
|
51
|
+
const total = entries.reduce((sum, [, entry]) => sum + entry.locations.length, 0);
|
|
52
|
+
const covered = Object.values(b).reduce((sum, hits) => sum + hits.filter((count) => count > 0).length, 0);
|
|
53
|
+
const pct = total === 0 ? 100 : Math.round((covered / total) * 1000) / 10;
|
|
54
|
+
return { total, covered, pct };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// "% Lines" (matching the printed coverage table's column, and lcov's LF/LH) is not raw statement
|
|
58
|
+
// count — Istanbul derives it by collapsing statements onto their start line, keeping the highest
|
|
59
|
+
// hit count per line (istanbul-lib-coverage's FileCoverage#getLineCoverage). Two statements on the
|
|
60
|
+
// same line count as one line; a statement spanning multiple lines only counts on its start line.
|
|
61
|
+
function lineCoverage(statementMap, s) {
|
|
62
|
+
const lineMap = new Map();
|
|
63
|
+
for (const [id, entry] of Object.entries(statementMap)) {
|
|
64
|
+
const line = entry.start.line;
|
|
65
|
+
const count = s[id];
|
|
66
|
+
if (!lineMap.has(line) || lineMap.get(line) < count) lineMap.set(line, count);
|
|
67
|
+
}
|
|
68
|
+
const total = lineMap.size;
|
|
69
|
+
const covered = [...lineMap.values()].filter((count) => count > 0).length;
|
|
70
|
+
const pct = total === 0 ? 100 : Math.round((covered / total) * 1000) / 10;
|
|
71
|
+
const uncovered = [...lineMap].filter(([, count]) => count === 0).map(([line]) => line).toSorted((a, b) => a - b);
|
|
72
|
+
return { total, covered, pct, uncovered };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const statements = summarize(file.statementMap, file.s);
|
|
76
|
+
const branches = summarizeBranches(file.branchMap, file.b);
|
|
77
|
+
const functions = summarize(file.fnMap, file.f);
|
|
78
|
+
const lines = lineCoverage(file.statementMap, file.s);
|
|
79
|
+
|
|
80
|
+
console.log(matches[0]);
|
|
81
|
+
console.log(`Statements: ${statements.covered}/${statements.total} (${statements.pct}%)`);
|
|
82
|
+
console.log(`Branches: ${branches.covered}/${branches.total} (${branches.pct}%)`);
|
|
83
|
+
console.log(`Functions: ${functions.covered}/${functions.total} (${functions.pct}%)`);
|
|
84
|
+
console.log(`Lines: ${lines.covered}/${lines.total} (${lines.pct}%)`);
|
|
85
|
+
console.log(`Uncovered line #s: ${lines.uncovered.length > 0 ? lines.uncovered.join(', ') : 'none'}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Drives one manifest entry against a freshly launched app: types the setup commands into the
|
|
2
|
+
// command bar, performs the staged actions, waits for the shot to stabilize, and saves the PNG.
|
|
3
|
+
const VIEWPORT = { width: 768, height: 768 };
|
|
4
|
+
const DEFAULT_SETTLE_MS = 800;
|
|
5
|
+
// 2x (retina) scale, applied to every shot, so doc pages stay crisp without oversized PNGs.
|
|
6
|
+
const SCALE = 2;
|
|
7
|
+
const CHILD_CROP_PAD = 12;
|
|
8
|
+
|
|
9
|
+
async function typeCommand(page, text) {
|
|
10
|
+
const input = page.locator('.command textarea');
|
|
11
|
+
await input.waitFor({ state: 'visible' });
|
|
12
|
+
await input.click();
|
|
13
|
+
await page.keyboard.type(text);
|
|
14
|
+
await page.keyboard.press('Enter');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function runActions(page, actions) {
|
|
18
|
+
for (const action of actions) {
|
|
19
|
+
if (action.type !== undefined) await page.keyboard.type(action.type);
|
|
20
|
+
if (action.press !== undefined) await page.keyboard.press(action.press);
|
|
21
|
+
if (action.wait !== undefined) await page.waitForTimeout(action.wait);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The busy dot is fully off for half of every 1.2s blink cycle; wait for its lit phase so the
|
|
26
|
+
// still can't catch it dark.
|
|
27
|
+
async function stabilize(page, kind) {
|
|
28
|
+
if (kind !== 'busy-dot') return;
|
|
29
|
+
await page.waitForFunction(
|
|
30
|
+
() => {
|
|
31
|
+
const dot = globalThis.document.querySelector('.tab .dot.busy');
|
|
32
|
+
return dot !== null && globalThis.getComputedStyle(dot).opacity === '1';
|
|
33
|
+
},
|
|
34
|
+
undefined,
|
|
35
|
+
{ timeout: 10_000 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The region to capture: the target element's box, optionally narrowed to its children (a tab
|
|
40
|
+
// strip is viewport-wide but its tabs occupy the left edge) and/or cut below `clipHeight` (tall
|
|
41
|
+
// bodies whose content sits at the top would otherwise be mostly empty space).
|
|
42
|
+
async function elementClip(page, entry) {
|
|
43
|
+
const container = page.locator(`[data-doc-shot="${entry.target}"]`).first();
|
|
44
|
+
await container.waitFor({ state: 'visible' });
|
|
45
|
+
const box = await container.boundingBox();
|
|
46
|
+
let { width, height } = box;
|
|
47
|
+
if (entry.cropToChildren) {
|
|
48
|
+
const children = await container.locator(entry.cropToChildren).all();
|
|
49
|
+
let maxRight = box.x;
|
|
50
|
+
for (const child of children) {
|
|
51
|
+
const childBox = await child.boundingBox();
|
|
52
|
+
if (childBox) maxRight = Math.max(maxRight, childBox.x + childBox.width);
|
|
53
|
+
}
|
|
54
|
+
width = Math.min(width, maxRight - box.x + CHILD_CROP_PAD);
|
|
55
|
+
}
|
|
56
|
+
if (entry.clipHeight) height = Math.min(height, entry.clipHeight);
|
|
57
|
+
return { x: box.x, y: box.y, width, height };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function captureShot(browser, url, entry, outputPath) {
|
|
61
|
+
const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE });
|
|
62
|
+
try {
|
|
63
|
+
const page = await context.newPage();
|
|
64
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
65
|
+
await page.locator('.command textarea').waitFor({ state: 'visible' });
|
|
66
|
+
const setupCommands = entry.setup ?? [];
|
|
67
|
+
for (const command of setupCommands) {
|
|
68
|
+
await typeCommand(page, command);
|
|
69
|
+
await page.waitForTimeout(entry.settle ?? DEFAULT_SETTLE_MS);
|
|
70
|
+
}
|
|
71
|
+
await runActions(page, entry.actions ?? []);
|
|
72
|
+
if (entry.actions?.length) await page.waitForTimeout(400);
|
|
73
|
+
await stabilize(page, entry.stabilize);
|
|
74
|
+
if (entry.target === 'page') {
|
|
75
|
+
await page.screenshot({ path: outputPath });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await page.screenshot({ path: outputPath, clip: await elementClip(page, entry) });
|
|
79
|
+
} finally {
|
|
80
|
+
await context.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Harbor Gazette</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font-family: Georgia, serif; margin: 0; background: #f7f4ee; color: #22211e; }
|
|
8
|
+
header { background: #1d3557; color: #f1faee; padding: 24px 32px; }
|
|
9
|
+
header h1 { margin: 0; font-size: 28px; }
|
|
10
|
+
header p { margin: 4px 0 0; color: #a8dadc; }
|
|
11
|
+
main { padding: 24px 32px; max-width: 720px; }
|
|
12
|
+
article h2 { margin-top: 0; }
|
|
13
|
+
a { color: #1d3557; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<header>
|
|
18
|
+
<h1>The Harbor Gazette</h1>
|
|
19
|
+
<p>Local news for the embedded-page screenshot</p>
|
|
20
|
+
</header>
|
|
21
|
+
<main>
|
|
22
|
+
<article>
|
|
23
|
+
<h2>Third bell still unexplained</h2>
|
|
24
|
+
<p>Harbor officials confirmed on Tuesday that the third bell, which rings each evening
|
|
25
|
+
shortly after the north pier lights come on, remains unaccounted for. "We only installed
|
|
26
|
+
two," said the harbormaster.</p>
|
|
27
|
+
<p>Residents are invited to submit theories at the town hall. <a href="#">Read more</a>.</p>
|
|
28
|
+
</article>
|
|
29
|
+
</main>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "number": 2 }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "number": 1 }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Field notes
|
|
2
|
+
|
|
3
|
+
A small document that exercises the renderer: headings, a list, a table, and a code block.
|
|
4
|
+
|
|
5
|
+
## Observations
|
|
6
|
+
|
|
7
|
+
- The harbor lights come on at dusk, north pier first.
|
|
8
|
+
- Gulls prefer the fishing boats to the ferries.
|
|
9
|
+
- Nobody has explained the third bell.
|
|
10
|
+
|
|
11
|
+
## Tide table
|
|
12
|
+
|
|
13
|
+
| Day | High | Low |
|
|
14
|
+
| --- | ---- | --- |
|
|
15
|
+
| Mon | 06:12 | 12:40 |
|
|
16
|
+
| Tue | 07:03 | 13:29 |
|
|
17
|
+
| Wed | 07:55 | 14:17 |
|
|
18
|
+
|
|
19
|
+
## Sampling script
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const readings = tides.filter((t) => t.height > 1.5);
|
|
23
|
+
console.log(`kept ${readings.length} of ${tides.length}`);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
> Data collected between March and June. Treat Wednesday's numbers with suspicion.
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A tiny task tracker, here to give the editor screenshot some syntax to highlight.
|
|
2
|
+
export type Task = {
|
|
3
|
+
id: number;
|
|
4
|
+
title: string;
|
|
5
|
+
done: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const tasks: Task[] = [
|
|
9
|
+
{ id: 1, title: 'Chart the north pier', done: true },
|
|
10
|
+
{ id: 2, title: 'Count the gulls', done: false },
|
|
11
|
+
{ id: 3, title: 'Explain the third bell', done: false },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function complete(id: number): Task | undefined {
|
|
15
|
+
const task = tasks.find((candidate) => candidate.id === id);
|
|
16
|
+
if (task) {
|
|
17
|
+
task.done = true;
|
|
18
|
+
}
|
|
19
|
+
return task;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function remaining(): string {
|
|
23
|
+
const open = tasks.filter((candidate) => !candidate.done);
|
|
24
|
+
return `${open.length} task(s) remaining`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Spawns the app server under capture conditions: cwd in the scratch work directory, HOME in
|
|
2
|
+
// the scratch home directory, no app window. The server entry is launched directly (not via
|
|
3
|
+
// bin/janus.mjs, whose spawnSync child would outlive a kill of the launcher). The printed
|
|
4
|
+
// `__JANUS_URL__ <url>` line carries the session token, so capture connects without guessing.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const URL_MARKER = '__JANUS_URL__ ';
|
|
10
|
+
const URL_TIMEOUT_MS = 20_000;
|
|
11
|
+
|
|
12
|
+
function serverCommand(repoRoot) {
|
|
13
|
+
const compiled = path.join(repoRoot, 'dist', 'main.js');
|
|
14
|
+
if (existsSync(compiled)) return [compiled];
|
|
15
|
+
return [path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'), path.join(repoRoot, 'src', 'main.ts')];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function spawnJanus(repoRoot, scratch) {
|
|
19
|
+
const child = spawn(process.execPath, [...serverCommand(repoRoot), '--no-open'], {
|
|
20
|
+
cwd: scratch.work,
|
|
21
|
+
env: { ...process.env, HOME: scratch.home },
|
|
22
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
23
|
+
});
|
|
24
|
+
const url = new Promise((resolve, reject) => {
|
|
25
|
+
let buffered = '';
|
|
26
|
+
const timer = setTimeout(() => reject(new Error(`no URL from janus within ${URL_TIMEOUT_MS}ms`)), URL_TIMEOUT_MS);
|
|
27
|
+
child.stdout.on('data', (chunk) => {
|
|
28
|
+
buffered += String(chunk);
|
|
29
|
+
const line = buffered.split('\n').find((candidate) => candidate.startsWith(URL_MARKER));
|
|
30
|
+
if (line !== undefined) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve(line.slice(URL_MARKER.length).trim());
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on('data', () => {});
|
|
36
|
+
child.on('exit', (code) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
reject(new Error(`janus exited before printing its URL (code ${code})`));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return { child, url };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function killJanus(child) {
|
|
45
|
+
if (child.exitCode !== null) return;
|
|
46
|
+
const gone = new Promise((resolve) => child.once('exit', resolve));
|
|
47
|
+
child.kill('SIGTERM');
|
|
48
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
|
49
|
+
await gone;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The shot manifest for `./scripts/run.mjs docs-screenshots`. One entry per screenshot used by
|
|
2
|
+
// the pages in documentation/user-documentation/; the entry name is the output filename, so a
|
|
3
|
+
// doc page's image path (/screenshots/<name>.png) stays stable across reruns.
|
|
4
|
+
//
|
|
5
|
+
// Each entry may carry:
|
|
6
|
+
// setup commands typed into the command bar, one at a time (Enter after each).
|
|
7
|
+
// `{{PAGE_URL}}` is replaced with the fixture web server's URL.
|
|
8
|
+
// actions staged input after setup: { type } text without submitting, { press } a key
|
|
9
|
+
// (Playwright key syntax), { wait } milliseconds.
|
|
10
|
+
// target the data-doc-shot attribute of the element to crop to, or 'page' for the
|
|
11
|
+
// whole viewport.
|
|
12
|
+
// settle milliseconds to wait after each setup command (default 800).
|
|
13
|
+
// cropToChildren narrow the crop to the extent of these child elements (e.g. the tab strip
|
|
14
|
+
// spans the viewport but its tabs occupy the left edge).
|
|
15
|
+
// clipHeight cut the crop below this many CSS pixels, for tall bodies whose content sits
|
|
16
|
+
// at the top.
|
|
17
|
+
// stabilize 'busy-dot' waits for the blinking busy dot's lit phase, so a still can't
|
|
18
|
+
// catch it dark.
|
|
19
|
+
// requiresBinary skip this shot (with a warning) unless the binary is on PATH; those shots
|
|
20
|
+
// are captured manually on a machine that has it.
|
|
21
|
+
export default [
|
|
22
|
+
// Getting started: the window on first launch, and the tab strip's signals.
|
|
23
|
+
{ name: 'app-overview', setup: [], target: 'page' },
|
|
24
|
+
{
|
|
25
|
+
name: 'tabs-overview',
|
|
26
|
+
setup: ['agent bilal', 'shell sleep 30', 'agent cavus', 'msg janus info morning report ready'],
|
|
27
|
+
stabilize: 'busy-dot',
|
|
28
|
+
target: 'tab-strip',
|
|
29
|
+
cropToChildren: '.tab',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'tabs-groups',
|
|
33
|
+
setup: ['agent bilal', 'profile launch demo'],
|
|
34
|
+
settle: 1500,
|
|
35
|
+
target: 'tab-strip',
|
|
36
|
+
cropToChildren: '.tab',
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// Command bar.
|
|
40
|
+
{ name: 'tab-completion', actions: [{ type: 'open sa' }, { press: 'Tab' }], target: 'command-bar' },
|
|
41
|
+
{ name: 'shell-output', setup: ['shell ls -la'], settle: 1200, target: 'transcript', clipHeight: 400 },
|
|
42
|
+
{
|
|
43
|
+
name: 'history-picker',
|
|
44
|
+
setup: ['shell ls -la', 'shell git status', 'state'],
|
|
45
|
+
actions: [{ press: 'Control+r' }],
|
|
46
|
+
target: 'history-overlay',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
// The scratch fixture has no `ai/` directory, so seed one with a few realistically named task
|
|
50
|
+
// files before opening the picker — otherwise Ctrl+A would capture the empty `(no tasks)` state.
|
|
51
|
+
// They go under `ai/tasks/` to match the real layout, which means the picker opens on a single
|
|
52
|
+
// collapsed `tasks` row: expand it (Right) and step onto a file (Down) so the shot shows the
|
|
53
|
+
// task files with one selected, as the doc page describes.
|
|
54
|
+
name: 'task-picker',
|
|
55
|
+
setup: [
|
|
56
|
+
'shell mkdir -p ai/tasks',
|
|
57
|
+
'shell touch ai/tasks/build-a-feature.md ai/tasks/fix-a-small-issue.md ai/tasks/improve-test-coverage.md ai/tasks/merge-change-to-master.md ai/tasks/open-feature-pull-request.md ai/tasks/reduce-complexity.md',
|
|
58
|
+
],
|
|
59
|
+
actions: [{ press: 'Control+a' }, { press: 'ArrowRight' }, { press: 'ArrowDown' }],
|
|
60
|
+
target: 'task-overlay',
|
|
61
|
+
},
|
|
62
|
+
{ name: 'ghost-text', setup: ['shell git status'], actions: [{ type: 'shell git' }], target: 'command-bar' },
|
|
63
|
+
{
|
|
64
|
+
name: 'tab-navigator',
|
|
65
|
+
setup: ['shell ls -la', 'shell git status'],
|
|
66
|
+
actions: [{ press: 'Control+g' }, { type: 'shell' }],
|
|
67
|
+
target: 'tab-nav-overlay',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// View tabs.
|
|
71
|
+
{ name: 'image-tab', setup: ['open ./sample.png'], actions: [{ press: 'PageUp' }], target: 'image-view' },
|
|
72
|
+
{ name: 'markdown-tab', setup: ['open ./sample.md'], target: 'markdown-view' },
|
|
73
|
+
{ name: 'page-tab', setup: ['open {{PAGE_URL}}'], settle: 2000, target: 'page-view' },
|
|
74
|
+
{
|
|
75
|
+
name: 'editor-tab',
|
|
76
|
+
setup: ['edit ./sample.ts'],
|
|
77
|
+
actions: [{ type: '// TODO: tighten these types' }],
|
|
78
|
+
target: 'editor-view',
|
|
79
|
+
clipHeight: 560,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'file-tree',
|
|
83
|
+
setup: ['files .'],
|
|
84
|
+
actions: [{ press: 'ArrowDown' }, { press: 'ArrowDown' }, { press: 'ArrowRight' }, { wait: 500 }],
|
|
85
|
+
target: 'file-tree-view',
|
|
86
|
+
clipHeight: 380,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'file-tree-sidebar',
|
|
90
|
+
setup: ['files left .'],
|
|
91
|
+
settle: 1000,
|
|
92
|
+
target: 'page',
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Advanced agents and automation.
|
|
96
|
+
{
|
|
97
|
+
name: 'workspaced-agent',
|
|
98
|
+
setup: ['agent emrah --workspace', 'shell pwd'],
|
|
99
|
+
settle: 2500,
|
|
100
|
+
target: 'status-panels',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'schedule-window',
|
|
104
|
+
setup: ['schedule standup every day at 9:00 state', 'schedule tests every 2h shell ls'],
|
|
105
|
+
target: 'status-panels',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'profile-group',
|
|
109
|
+
setup: ['profile launch demo'],
|
|
110
|
+
settle: 1500,
|
|
111
|
+
target: 'tab-strip',
|
|
112
|
+
cropToChildren: '.tab',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'harness-tab',
|
|
116
|
+
setup: ['harness claude'],
|
|
117
|
+
settle: 5000,
|
|
118
|
+
target: 'harness-view',
|
|
119
|
+
clipHeight: 540,
|
|
120
|
+
requiresBinary: 'claude',
|
|
121
|
+
},
|
|
122
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The disposable directory a screenshot run works in: a `work` directory seeded from the
|
|
2
|
+
// fixtures (made into a git repo with a local `origin`, so workspaced shots can clone), and a
|
|
3
|
+
// `home` directory so the app's homedir-scoped state (the global command history) never reads
|
|
4
|
+
// or pollutes the real one.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
// Fixture commits need an identity that works on any machine, without touching real git config.
|
|
13
|
+
const GIT_IDENTITY = ['-c', 'user.name=docs-screenshots', '-c', 'user.email=docs-screenshots@example.invalid'];
|
|
14
|
+
|
|
15
|
+
function git(cwd, ...gitArguments) {
|
|
16
|
+
execFileSync('git', [...GIT_IDENTITY, ...gitArguments], { cwd, stdio: 'ignore' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createScratch(fixturesDirectory) {
|
|
20
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'janus-docs-'));
|
|
21
|
+
// Named like a real project ("harbor", matching the fixtures' theme) because the directory
|
|
22
|
+
// name shows up in captured metadata headers.
|
|
23
|
+
const work = path.join(root, 'harbor');
|
|
24
|
+
const home = path.join(root, 'home');
|
|
25
|
+
mkdirSync(work);
|
|
26
|
+
mkdirSync(home);
|
|
27
|
+
cpSync(fixturesDirectory, work, { recursive: true });
|
|
28
|
+
// page.html is served by the fixture web server, not shown in the file tree.
|
|
29
|
+
rmSync(path.join(work, 'page.html'));
|
|
30
|
+
git(work, 'init', '--quiet');
|
|
31
|
+
git(work, 'add', '--all');
|
|
32
|
+
git(work, 'commit', '--quiet', '--message', 'fixtures');
|
|
33
|
+
const origin = path.join(root, 'origin.git');
|
|
34
|
+
git(root, 'clone', '--bare', '--quiet', work, origin);
|
|
35
|
+
git(work, 'remote', 'add', 'origin', origin);
|
|
36
|
+
return { root, work, home };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function destroyScratch(scratch) {
|
|
40
|
+
rmSync(scratch.root, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A tiny local server for the embedded-web-page shot, so capture never depends on the network
|
|
44
|
+
// or an external site staying up and stable.
|
|
45
|
+
export async function startPageServer(fixturesDirectory) {
|
|
46
|
+
const html = await readFile(path.join(fixturesDirectory, 'page.html'));
|
|
47
|
+
const server = http.createServer((request, response) => {
|
|
48
|
+
response.writeHead(200, { 'content-type': 'text/html' });
|
|
49
|
+
response.end(html);
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
52
|
+
return { url: `http://127.0.0.1:${server.address().port}/`, close: () => server.close() };
|
|
53
|
+
}
|