@marcomigozzi/trailhead 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +28 -18
- package/README.md +28 -18
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/engine-route-docs.test.js +7 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +8 -8
- package/bin/lib/codex-projection.test.js +19 -3
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +9 -4
- package/bin/trailhead.test.js +18 -6
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-grill.md +2 -2
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +30 -14
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
- package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
- package/plugins/trailhead/skills/_shared/substrate.md +3 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +2 -1
- package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
- package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
20
|
const { spawnSync } = require('child_process');
|
|
21
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
21
22
|
|
|
22
23
|
const LABEL = 'trailhead:uat';
|
|
23
24
|
const COLOR = 'BFD4F2';
|
|
@@ -27,7 +28,7 @@ const LIMIT = 1000;
|
|
|
27
28
|
const UAT_LINE_RE = /^UAT of:/m;
|
|
28
29
|
|
|
29
30
|
function defaultGh(args) {
|
|
30
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
31
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
31
32
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
// Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
|
|
26
26
|
|
|
27
27
|
const { spawnSync } = require('child_process');
|
|
28
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
28
29
|
|
|
29
30
|
const MAX_PAGES = 100; // 100 pages x 100 issues
|
|
30
31
|
const QUERY = 'query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:100,after:$after,labels:["trailhead:uat"],states:[OPEN,CLOSED],orderBy:{field:CREATED_AT,direction:ASC}){nodes{number title state body} pageInfo{hasNextPage endCursor}}}}';
|
|
31
32
|
const URL_RE = /https:\/\/github\.com\/([^/\s)]+)\/([^/\s)]+)\/issues\/(\d+)(?![\w])/gi;
|
|
32
33
|
|
|
33
34
|
function defaultGh(args) {
|
|
34
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
35
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
35
36
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -46,6 +46,7 @@ const fs = require('fs');
|
|
|
46
46
|
const os = require('os');
|
|
47
47
|
const path = require('path');
|
|
48
48
|
const { spawnSync } = require('child_process');
|
|
49
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
49
50
|
|
|
50
51
|
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
51
52
|
const OUTPUT_TAIL = 2000;
|
|
@@ -118,8 +119,12 @@ function pick(cfg, key, file) {
|
|
|
118
119
|
return out;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
|
|
123
|
+
const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
|
|
124
|
+
const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
|
|
125
|
+
|
|
121
126
|
function globalConfigPath(env) {
|
|
122
|
-
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
127
|
+
const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
123
128
|
return path.join(base, 'trailhead', 'config.json');
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -257,7 +262,7 @@ function redactor(names, env) {
|
|
|
257
262
|
function sh(command, repo, env, timeoutMs, wantOutput) {
|
|
258
263
|
const timeout = Math.max(1, Math.ceil(timeoutMs));
|
|
259
264
|
if (!wantOutput) {
|
|
260
|
-
const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
|
|
265
|
+
const r = spawnSync('sh', ['-c', command], { cwd: repo, env: gitEnv(env), stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
|
|
261
266
|
const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
|
|
262
267
|
return { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
|
|
263
268
|
}
|
|
@@ -265,7 +270,7 @@ function sh(command, repo, env, timeoutMs, wantOutput) {
|
|
|
265
270
|
const file = path.join(dir, 'out');
|
|
266
271
|
const fd = fs.openSync(file, 'w');
|
|
267
272
|
try {
|
|
268
|
-
const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
|
|
273
|
+
const r = spawnSync('sh', ['-c', command], { cwd: repo, env: gitEnv(env), stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
|
|
269
274
|
const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
|
|
270
275
|
const res = { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
|
|
271
276
|
if (wantOutput) {
|
|
@@ -377,7 +382,7 @@ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
|
|
|
377
382
|
return 0;
|
|
378
383
|
}
|
|
379
384
|
|
|
380
|
-
module.exports = { parseArgs, readConfig, main };
|
|
385
|
+
module.exports = { globalConfigPath, parseArgs, readConfig, main };
|
|
381
386
|
|
|
382
387
|
if (require.main === module) {
|
|
383
388
|
const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
|
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
// Exit 0 on ok, 1 on an error, 2 on a usage error (stderr).
|
|
22
22
|
|
|
23
23
|
const { spawnSync } = require('child_process');
|
|
24
|
+
const { gitEnv } = require('./trailhead-git-env.js');
|
|
24
25
|
|
|
25
26
|
const LABEL = 'trailhead:blocked';
|
|
26
27
|
|
|
27
28
|
function defaultGh(args) {
|
|
28
|
-
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
29
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
29
30
|
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { On } from 'claude-code'
|
|
2
2
|
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
import { unscrub } from './unscrub'
|
|
3
4
|
|
|
4
5
|
import { resetAutoRun } from '../hooks/mod/auto-run'
|
|
5
6
|
import { resetLocate } from '../hooks/mod/work-state-locate'
|
|
@@ -47,7 +48,7 @@ const rig = (on: On) => {
|
|
|
47
48
|
on('session.cwd', () => ({ value: r.cwd }))
|
|
48
49
|
on('session.id', () => ({ value: SESSION }))
|
|
49
50
|
on('process.run', (_$, e) => {
|
|
50
|
-
const line = e.argv.join(' ')
|
|
51
|
+
const line = unscrub(e.argv).join(' ')
|
|
51
52
|
r.runs.push(line)
|
|
52
53
|
r.cwds.push(e.cwd ?? '')
|
|
53
54
|
const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { GIT_LOCATION_VARS, gitArgv, scrubGitEnv } from '../hooks/mod/git-env'
|
|
4
|
+
import { unscrub } from './unscrub'
|
|
5
|
+
|
|
6
|
+
test('gitArgv wraps git in env -u for every repo-location variable', () => {
|
|
7
|
+
const argv = gitArgv('-C', '/repo', 'status')
|
|
8
|
+
expect(argv.slice(0, 3)).toEqual(['env', '-u', GIT_LOCATION_VARS[0]])
|
|
9
|
+
for (const name of GIT_LOCATION_VARS) expect(argv).toContain(name)
|
|
10
|
+
expect(argv.slice(-4)).toEqual(['git', '-C', '/repo', 'status'])
|
|
11
|
+
expect(unscrub(argv)).toEqual(['git', '-C', '/repo', 'status'])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('GIT_DIR and GIT_WORK_TREE are scrubbed, GIT_CONFIG* is not', () => {
|
|
15
|
+
const argv = scrubGitEnv(['gh', 'api', 'graphql'])
|
|
16
|
+
expect(argv).toContain('GIT_DIR')
|
|
17
|
+
expect(argv).toContain('GIT_WORK_TREE')
|
|
18
|
+
expect(argv.some(a => a.startsWith('GIT_CONFIG'))).toBe(false)
|
|
19
|
+
expect(unscrub(argv)).toEqual(['gh', 'api', 'graphql'])
|
|
20
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { On } from 'claude-code'
|
|
2
2
|
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
+
import { unscrub } from './unscrub'
|
|
3
4
|
|
|
4
5
|
const SURFACES = ['terminal', 'desktop'] as const
|
|
5
6
|
const SESSION = 'sess-A'
|
|
@@ -383,15 +384,18 @@ const rig = (on: On, engine?: any) => {
|
|
|
383
384
|
return { value: undefined }
|
|
384
385
|
})
|
|
385
386
|
on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
|
|
387
|
+
// A checkout in `gone` no longer exists on disk; nothing else is listed beside it.
|
|
388
|
+
on('fs.exists', (_$, e) => ({ value: !r.gone.includes(e.path) }))
|
|
389
|
+
on('fs.list', () => ({ value: [] }))
|
|
386
390
|
on('session.root', () => ({ value: r.sessionRoot }))
|
|
387
391
|
on('session.cwd', () => ({ value: r.cwd }))
|
|
388
392
|
on('process.run', async (_$, e) => {
|
|
389
393
|
// The pin's own `git rev-parse --show-toplevel`: answered apart, never counted as a `gh` call.
|
|
390
|
-
if (e.argv[0] === 'git') {
|
|
391
|
-
r.gitCalls.push({ argv: [...e.argv], cwd: e.init?.cwd })
|
|
394
|
+
if (unscrub(e.argv)[0] === 'git') {
|
|
395
|
+
r.gitCalls.push({ argv: [...unscrub(e.argv)], cwd: e.init?.cwd })
|
|
392
396
|
// Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
|
|
393
397
|
const where = e.init?.cwd
|
|
394
|
-
if (e.argv[1] === 'remote') {
|
|
398
|
+
if (unscrub(e.argv)[1] === 'remote') {
|
|
395
399
|
const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
|
|
396
400
|
return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
397
401
|
}
|
|
@@ -400,29 +404,31 @@ const rig = (on: On, engine?: any) => {
|
|
|
400
404
|
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
401
405
|
}
|
|
402
406
|
// The pin's host check: answered apart, never counted as a read of the pane.
|
|
403
|
-
if (e.argv[1] === 'auth') {
|
|
404
|
-
const host = e.argv[4] ?? ''
|
|
407
|
+
if (unscrub(e.argv)[1] === 'auth') {
|
|
408
|
+
const host = unscrub(e.argv)[4] ?? ''
|
|
405
409
|
r.authCalls.push(host)
|
|
406
410
|
const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
|
|
407
411
|
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
408
412
|
}
|
|
409
|
-
if (isLoginRead(e.argv) || isMineRead(e.argv)) {
|
|
410
|
-
r.mineCalls.push([...e.argv])
|
|
411
|
-
const body = isLoginRead(e.argv)
|
|
413
|
+
if (isLoginRead(unscrub(e.argv)) || isMineRead(unscrub(e.argv))) {
|
|
414
|
+
r.mineCalls.push([...unscrub(e.argv)])
|
|
415
|
+
const body = isLoginRead(unscrub(e.argv))
|
|
412
416
|
? { data: { viewer: { login: 'me' } } }
|
|
413
417
|
: { data: { repository: { issues: { nodes: (r.mine ?? []).map(flatNode), pageInfo: { hasNextPage: false, endCursor: null } } } } }
|
|
414
|
-
const out = r.mine === null && isMineRead(e.argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
|
|
418
|
+
const out = r.mine === null && isMineRead(unscrub(e.argv)) ? { exitCode: 1, stdout: '', stderr: 'boom' } : { exitCode: 0, stdout: JSON.stringify(body), stderr: '' }
|
|
415
419
|
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
416
420
|
}
|
|
417
|
-
r.calls.push([...e.argv])
|
|
421
|
+
r.calls.push([...unscrub(e.argv)])
|
|
418
422
|
r.cwds.push(e.init?.cwd)
|
|
419
423
|
// `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
|
|
420
424
|
const where = e.init?.cwd ?? r.cwd
|
|
425
|
+
// A spawn in a deleted directory fails the way a missing binary does.
|
|
426
|
+
if (r.gone.includes(where)) return { deny: 'spawn gh ENOENT' }
|
|
421
427
|
if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
|
|
422
428
|
return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
423
429
|
}
|
|
424
430
|
if (r.deny !== null) return { deny: r.deny }
|
|
425
|
-
const out = await (r.gh ?? defaultGh)(e.argv)
|
|
431
|
+
const out = await (r.gh ?? defaultGh)(unscrub(e.argv))
|
|
426
432
|
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
427
433
|
})
|
|
428
434
|
on('ui.open', (_$, e) => {
|
|
@@ -444,6 +450,7 @@ const rig = (on: On, engine?: any) => {
|
|
|
444
450
|
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
445
451
|
on('turn.complete', () => ({ text: '' }))
|
|
446
452
|
on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
|
|
453
|
+
on('session.attach', (_$, e) => ({ clientId: e.clientId }))
|
|
447
454
|
on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
|
|
448
455
|
return r
|
|
449
456
|
}
|
|
@@ -457,6 +464,8 @@ const BAND_PROPS = {
|
|
|
457
464
|
scroll: { offset: 0, bodyRows: 10 },
|
|
458
465
|
view: {},
|
|
459
466
|
} as const
|
|
467
|
+
// What the command answers for an undrawn pane (never the host's raw reason).
|
|
468
|
+
const NOT_SHOWN = 'Map panel is open but not shown yet: it appears once the screen can place it.'
|
|
460
469
|
const FULLSCREEN = { columns: 200, rows: 50, isFullscreen: true } as const
|
|
461
470
|
|
|
462
471
|
const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
|
|
@@ -513,13 +522,24 @@ for (const surface of SURFACES) {
|
|
|
513
522
|
expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
|
|
514
523
|
expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
|
|
515
524
|
expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
|
|
516
|
-
expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
|
|
525
|
+
expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#9ece6a' } })
|
|
526
|
+
// ogni sezione ha il suo glifo e un filetto colorato davanti a ogni riga
|
|
527
|
+
expect(await textOf(ui, 'glyph-frontier')).toBe('◆ ')
|
|
528
|
+
expect(await textOf(ui, 'glyph-blocked')).toBe('■ ')
|
|
529
|
+
if (surface === 'terminal') {
|
|
530
|
+
expect(await textOf(ui, 'gutter-209')).toBe('│ ')
|
|
531
|
+
expect((await ui.find({ key: 'gutter-211' }))?.children[0]).toMatchObject({ props: { color: '#f7768e' } })
|
|
532
|
+
} else {
|
|
533
|
+
// sul desktop il filetto è una barra continua, alta quanto il blocco
|
|
534
|
+
expect((await ui.find({ key: 'gutter-211-bar' }))?.props).toMatchObject({ backgroundColor: '#f7768e' })
|
|
535
|
+
}
|
|
517
536
|
expect(await keysOf(ui, 'row-')).toEqual(['row-208', 'row-212', 'row-209', 'row-210', 'row-214', 'row-211', 'row-213'])
|
|
518
537
|
|
|
519
538
|
expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
|
|
520
539
|
expect(await textOf(ui, 'type-209')).toBe(' build')
|
|
521
|
-
expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: {
|
|
540
|
+
expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { color: '#7dcfff' } })
|
|
522
541
|
expect(await textOf(ui, 'type-214')).toBe(' decision')
|
|
542
|
+
expect((await ui.find({ key: 'type-214' }))?.children[0]).toMatchObject({ props: { color: '#ff9e64' } })
|
|
523
543
|
const links = (await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)
|
|
524
544
|
expect(links).toContain(`${URL}/209`)
|
|
525
545
|
|
|
@@ -545,6 +565,7 @@ for (const surface of SURFACES) {
|
|
|
545
565
|
// the quarantined ticket is counted, never listed
|
|
546
566
|
expect(await ui.find({ key: 'row-215' })).toBeUndefined()
|
|
547
567
|
expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
|
|
568
|
+
expect((await ui.find({ key: 'unverified' }))?.children[0]).toMatchObject({ props: { color: '#e0af68' } })
|
|
548
569
|
expect(await textOf(ui, 'age')).toBe('updated just now')
|
|
549
570
|
})
|
|
550
571
|
|
|
@@ -578,6 +599,10 @@ for (const surface of SURFACES) {
|
|
|
578
599
|
// the parent line, dim, under the row; none without a parent
|
|
579
600
|
expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
|
|
580
601
|
expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
|
|
602
|
+
// la riga del genitore sta nel blocco del ticket, dopo il filetto che copre entrambe le righe
|
|
603
|
+
expect(await keysOf(ui, 'entry-lines-240')).toEqual(['entry-lines-240'])
|
|
604
|
+
expect(await textOf(ui, 'entry-lines-240')).toContain('tests Burn-down view of the map #209')
|
|
605
|
+
if (surface === 'terminal') expect(await textOf(ui, 'gutter-240')).toBe('│ │ ')
|
|
581
606
|
expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
|
|
582
607
|
expect(await hasKey(ui, 'uat-of-242')).toBe(false)
|
|
583
608
|
expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
|
|
@@ -1125,6 +1150,17 @@ for (const surface of SURFACES) {
|
|
|
1125
1150
|
expect(await reasonFor(() => (r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })))).toMatch(/^⚠ gh failed \(exit 1\) · list from /)
|
|
1126
1151
|
})
|
|
1127
1152
|
|
|
1153
|
+
test(`a pinned checkout that is gone is named, not blamed on gh [${surface}]`, async ($, on) => {
|
|
1154
|
+
const r = rig(on, $)
|
|
1155
|
+
await $.session.start(START)
|
|
1156
|
+
const ui = await openAndMount($, r)
|
|
1157
|
+
r.gone = [ROOT]
|
|
1158
|
+
await r.clock.advance(FLOOR)
|
|
1159
|
+
await completeTurn($)
|
|
1160
|
+
await settle(r)
|
|
1161
|
+
expect(await textOf(ui, 'age')).toMatch(/^⚠ checkout not found: \/repo · list from /)
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1128
1164
|
test(`a first fetch that fails shows the reason without a list [${surface}]`, async ($, on) => {
|
|
1129
1165
|
const r = rig(on, $)
|
|
1130
1166
|
r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })
|
|
@@ -1298,6 +1334,22 @@ for (const surface of SURFACES) {
|
|
|
1298
1334
|
expect(listReads(r)).toEqual([])
|
|
1299
1335
|
})
|
|
1300
1336
|
|
|
1337
|
+
// A heredoc body is data: a write-looking line inside one starts no read, a real write after it still does (#369).
|
|
1338
|
+
test(`a heredoc body that names a tracker write starts no pane read [${surface}]`, async ($, on) => {
|
|
1339
|
+
const r = rig(on, $)
|
|
1340
|
+
await $.session.start(START)
|
|
1341
|
+
await openAndMount($, r)
|
|
1342
|
+
await r.clock.advance(FLOOR)
|
|
1343
|
+
const before = fetches(r)
|
|
1344
|
+
await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\ngh issue close 209\nEOF" } as never)
|
|
1345
|
+
await settle(r)
|
|
1346
|
+
expect(fetches(r)).toBe(before)
|
|
1347
|
+
|
|
1348
|
+
await $.tool.call({ tool: 'Bash', command: "cat > /tmp/x.md <<'EOF'\nbody\nEOF\ngh issue close 209" } as never)
|
|
1349
|
+
await settle(r)
|
|
1350
|
+
expect(fetches(r)).toBe(before + 1)
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1301
1353
|
// The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
|
|
1302
1354
|
// list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
|
|
1303
1355
|
test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
|
|
@@ -1480,6 +1532,34 @@ for (const surface of SURFACES) {
|
|
|
1480
1532
|
}
|
|
1481
1533
|
})
|
|
1482
1534
|
|
|
1535
|
+
test(`a multi-line Bash write refreshes, a multi-line read does not [${surface}]`, async ($, on) => {
|
|
1536
|
+
const r = rig(on, $)
|
|
1537
|
+
await $.session.start(START)
|
|
1538
|
+
await openAndMount($, r)
|
|
1539
|
+
const heredoc = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
|
|
1540
|
+
const base = fetches(r)
|
|
1541
|
+
for (const command of [`${heredoc}\ngh issue view 5`, `${heredoc}\ngh issue list --state open`]) {
|
|
1542
|
+
await r.clock.advance(FLOOR)
|
|
1543
|
+
await $.tool.call({ tool: 'Bash', command } as never)
|
|
1544
|
+
await settle(r)
|
|
1545
|
+
}
|
|
1546
|
+
expect(fetches(r)).toBe(base)
|
|
1547
|
+
|
|
1548
|
+
for (const command of [`${heredoc}\ncd /repo && gh issue edit 325 --title "t"`, `${heredoc}\ngh issue create --body-file /tmp/x.md`]) {
|
|
1549
|
+
// until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
|
|
1550
|
+
let seen = -1
|
|
1551
|
+
while (seen !== fetches(r)) {
|
|
1552
|
+
seen = fetches(r)
|
|
1553
|
+
await r.clock.advance(FLOOR)
|
|
1554
|
+
await settle(r)
|
|
1555
|
+
}
|
|
1556
|
+
const before = fetches(r)
|
|
1557
|
+
await $.tool.call({ tool: 'Bash', command } as never)
|
|
1558
|
+
await settle(r)
|
|
1559
|
+
expect([command, fetches(r)]).toEqual([command, before + 1])
|
|
1560
|
+
}
|
|
1561
|
+
})
|
|
1562
|
+
|
|
1483
1563
|
test(`closing the pane stops refreshing [${surface}]`, async ($, on) => {
|
|
1484
1564
|
const r = rig(on, $)
|
|
1485
1565
|
await $.session.start(START)
|
|
@@ -2387,14 +2467,43 @@ for (const surface of SURFACES) {
|
|
|
2387
2467
|
expect(r.logs).toEqual([])
|
|
2388
2468
|
})
|
|
2389
2469
|
|
|
2390
|
-
test(`/trailhead:panel says
|
|
2470
|
+
test(`/trailhead:panel says plainly that a pane cannot be placed yet [${surface}]`, async ($, on) => {
|
|
2391
2471
|
const r = rig(on, $)
|
|
2392
2472
|
r.isPlaced = false
|
|
2393
2473
|
await $.session.start(START)
|
|
2394
|
-
expect((await panel($)).text).toBe(
|
|
2474
|
+
expect((await panel($)).text).toBe(NOT_SHOWN)
|
|
2395
2475
|
await settle(r)
|
|
2396
2476
|
expect(fetches(r)).toBe(0)
|
|
2397
|
-
// a pane
|
|
2477
|
+
// a pane the person never saw is not closed by a repeat: it stays open, undrawn, until it can be placed
|
|
2478
|
+
expect((await panel($)).text).toBe(NOT_SHOWN)
|
|
2479
|
+
expect((await panel($)).text).toBe(NOT_SHOWN)
|
|
2480
|
+
expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
|
|
2481
|
+
// placed at last, the command sees it drawn and closes it
|
|
2482
|
+
r.isPlaced = true
|
|
2483
|
+
expect((await panel($)).text).toBe('Map panel opened.')
|
|
2484
|
+
expect((await panel($)).text).toBe('Map panel closed.')
|
|
2485
|
+
expect(r.panes).toEqual([])
|
|
2486
|
+
})
|
|
2487
|
+
|
|
2488
|
+
// #348: l'auto-open su una superficie che non ha ancora comunicato il layout resta non disegnato; il comando
|
|
2489
|
+
// non deve chiudere un pannello che la persona non ha mai visto.
|
|
2490
|
+
test(`/trailhead:panel never closes a pane the auto-open left undrawn on a surface without layout [${surface}]`, async ($, on) => {
|
|
2491
|
+
const r = rig(on, $)
|
|
2492
|
+
r.isPlaced = false
|
|
2493
|
+
r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
|
|
2494
|
+
await $.session.start(START)
|
|
2495
|
+
await mountBand($, FULLSCREEN)
|
|
2496
|
+
await settle(r)
|
|
2497
|
+
expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
|
|
2498
|
+
|
|
2499
|
+
const shown = NOT_SHOWN
|
|
2500
|
+
expect((await panel($)).text).toBe(shown)
|
|
2501
|
+
expect((await panel($)).text).toBe(shown)
|
|
2502
|
+
expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
|
|
2503
|
+
|
|
2504
|
+
// the layout arrives: the command draws it, and only then does a repeat close it
|
|
2505
|
+
r.isPlaced = true
|
|
2506
|
+
expect((await panel($)).text).toBe('Map panel opened.')
|
|
2398
2507
|
expect((await panel($)).text).toBe('Map panel closed.')
|
|
2399
2508
|
})
|
|
2400
2509
|
|
|
@@ -2433,6 +2542,148 @@ for (const surface of SURFACES) {
|
|
|
2433
2542
|
expect(r.panes).toEqual([])
|
|
2434
2543
|
})
|
|
2435
2544
|
|
|
2545
|
+
// #348: un auto-open che resta non disegnato non conta come fatto, così un trigger successivo può riprovare.
|
|
2546
|
+
test(`an auto-open left undrawn does not end the auto-open, a drawn pane does [${surface}]`, async ($, on) => {
|
|
2547
|
+
const r = rig(on, $)
|
|
2548
|
+
r.isPlaced = false
|
|
2549
|
+
r.reason = 'attached surfaces place no panes: desktop has not reported its layout'
|
|
2550
|
+
await $.session.start(START)
|
|
2551
|
+
await mountBand($, FULLSCREEN)
|
|
2552
|
+
await settle(r)
|
|
2553
|
+
expect(r.opens).toHaveLength(1)
|
|
2554
|
+
|
|
2555
|
+
// listed but undrawn: further renders do not open it again (the host places it when it can)
|
|
2556
|
+
await mountBand($, FULLSCREEN)
|
|
2557
|
+
await settle(r)
|
|
2558
|
+
expect(r.opens).toHaveLength(1)
|
|
2559
|
+
|
|
2560
|
+
// the host dropped it while still undrawn: the next render tries again
|
|
2561
|
+
r.panes = []
|
|
2562
|
+
await mountBand($, FULLSCREEN)
|
|
2563
|
+
await settle(r)
|
|
2564
|
+
expect(r.opens).toHaveLength(2)
|
|
2565
|
+
|
|
2566
|
+
// drawn at last: its own render completes the auto-open for good
|
|
2567
|
+
r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
|
|
2568
|
+
await mountPane($)
|
|
2569
|
+
await settle(r)
|
|
2570
|
+
r.panes = []
|
|
2571
|
+
await mountBand($, FULLSCREEN)
|
|
2572
|
+
await settle(r)
|
|
2573
|
+
expect(r.opens).toHaveLength(2)
|
|
2574
|
+
})
|
|
2575
|
+
|
|
2576
|
+
test(`an auto-open that finds the pane listed and drawn ends the auto-open [${surface}]`, async ($, on) => {
|
|
2577
|
+
const r = rig(on, $)
|
|
2578
|
+
r.isPlaced = false
|
|
2579
|
+
await $.session.start(START)
|
|
2580
|
+
await mountBand($, FULLSCREEN)
|
|
2581
|
+
await settle(r)
|
|
2582
|
+
expect(r.opens).toHaveLength(1)
|
|
2583
|
+
|
|
2584
|
+
// placed by the host with no pane render yet (the pane's render needs the dock): the next band render sees it
|
|
2585
|
+
r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
|
|
2586
|
+
await mountBand($, FULLSCREEN)
|
|
2587
|
+
await settle(r)
|
|
2588
|
+
r.panes = []
|
|
2589
|
+
await mountBand($, FULLSCREEN)
|
|
2590
|
+
await settle(r)
|
|
2591
|
+
expect(r.opens).toHaveLength(1)
|
|
2592
|
+
})
|
|
2593
|
+
|
|
2594
|
+
test(`the person's own request on an undrawn pane ends the auto-open, a close of it stays closed [${surface}]`, async ($, on) => {
|
|
2595
|
+
const r = rig(on, $)
|
|
2596
|
+
r.isPlaced = false
|
|
2597
|
+
await $.session.start(START)
|
|
2598
|
+
await mountBand($, FULLSCREEN)
|
|
2599
|
+
await settle(r)
|
|
2600
|
+
expect(r.opens).toHaveLength(1)
|
|
2601
|
+
|
|
2602
|
+
// the command is the person acting on it: the auto-open is done even though the pane is still undrawn
|
|
2603
|
+
expect((await panel($)).text).toBe(NOT_SHOWN)
|
|
2604
|
+
expect(r.opens).toHaveLength(2)
|
|
2605
|
+
r.panes = []
|
|
2606
|
+
await mountBand($, FULLSCREEN)
|
|
2607
|
+
await settle(r)
|
|
2608
|
+
expect(r.opens).toHaveLength(2)
|
|
2609
|
+
})
|
|
2610
|
+
|
|
2611
|
+
// Il desktop avvia la sessione al primo invio: il comando che l'ha avviata arriva subito dopo l'auto-open.
|
|
2612
|
+
test(`/trailhead:panel right after the auto-open keeps the pane the person never saw [${surface}]`, async ($, on) => {
|
|
2613
|
+
const r = rig(on, $)
|
|
2614
|
+
await $.session.start(START)
|
|
2615
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
|
|
2616
|
+
await settle(r)
|
|
2617
|
+
expect(r.panes.map(p => p.id)).toEqual([PANE])
|
|
2618
|
+
|
|
2619
|
+
const opened = await panel($)
|
|
2620
|
+
expect(opened.text).toBe('Map panel opened.')
|
|
2621
|
+
expect(r.panes.map(p => p.id)).toEqual([PANE])
|
|
2622
|
+
|
|
2623
|
+
// the next press acts on a pane the person asked for: it closes, however soon
|
|
2624
|
+
const closed = await panel($)
|
|
2625
|
+
expect(closed.text).toBe('Map panel closed.')
|
|
2626
|
+
expect(r.panes).toEqual([])
|
|
2627
|
+
})
|
|
2628
|
+
|
|
2629
|
+
test(`/trailhead:panel closes an auto-opened pane once it has been on screen [${surface}]`, async ($, on) => {
|
|
2630
|
+
const r = rig(on, $)
|
|
2631
|
+
await $.session.start(START)
|
|
2632
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
|
|
2633
|
+
await settle(r)
|
|
2634
|
+
expect(r.panes.map(p => p.id)).toEqual([PANE])
|
|
2635
|
+
|
|
2636
|
+
await r.clock.advance(2_000)
|
|
2637
|
+
const closed = await panel($)
|
|
2638
|
+
expect(closed.text).toBe('Map panel closed.')
|
|
2639
|
+
expect(r.panes).toEqual([])
|
|
2640
|
+
})
|
|
2641
|
+
|
|
2642
|
+
test(`a surface attaching in fullscreen retries the auto-open [${surface}]`, async ($, on) => {
|
|
2643
|
+
const r = rig(on, $)
|
|
2644
|
+
r.isPlaced = false
|
|
2645
|
+
r.reason = 'no-surface: no surface is attached ($.session.surfaces() is empty)'
|
|
2646
|
+
await $.session.start(START)
|
|
2647
|
+
await settle(r)
|
|
2648
|
+
expect(r.opens).toEqual([])
|
|
2649
|
+
|
|
2650
|
+
// not fullscreen, or no layout said: the render trigger decides
|
|
2651
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: { columns: 200, rows: 50, isFullscreen: false } } as never)
|
|
2652
|
+
await $.session.attach({ surface, clientId: `${surface}:default` } as never)
|
|
2653
|
+
await settle(r)
|
|
2654
|
+
expect(r.opens).toEqual([])
|
|
2655
|
+
|
|
2656
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
|
|
2657
|
+
await settle(r)
|
|
2658
|
+
expect(r.opens).toHaveLength(1)
|
|
2659
|
+
|
|
2660
|
+
// still undrawn and listed: another attach does not open again
|
|
2661
|
+
await $.session.attach({ surface, clientId: `${surface}:other`, viewport: FULLSCREEN } as never)
|
|
2662
|
+
await settle(r)
|
|
2663
|
+
expect(r.opens).toHaveLength(1)
|
|
2664
|
+
})
|
|
2665
|
+
|
|
2666
|
+
test(`an attach never opens the pane after the person closed it [${surface}]`, async ($, on) => {
|
|
2667
|
+
const r = rig(on, $)
|
|
2668
|
+
await $.session.start(START)
|
|
2669
|
+
await panel($)
|
|
2670
|
+
await panel($)
|
|
2671
|
+
expect(r.panes).toEqual([])
|
|
2672
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
|
|
2673
|
+
await settle(r)
|
|
2674
|
+
expect(r.opens).toHaveLength(1)
|
|
2675
|
+
|
|
2676
|
+
})
|
|
2677
|
+
|
|
2678
|
+
test(`an attach without an active map does not open the pane [${surface}]`, async ($, on) => {
|
|
2679
|
+
const r = rig(on, $)
|
|
2680
|
+
r.active = null
|
|
2681
|
+
await $.session.start(START)
|
|
2682
|
+
await $.session.attach({ surface, clientId: `${surface}:default`, viewport: FULLSCREEN } as never)
|
|
2683
|
+
await settle(r)
|
|
2684
|
+
expect(r.opens).toEqual([])
|
|
2685
|
+
})
|
|
2686
|
+
|
|
2436
2687
|
for (const withBand of [false, true]) {
|
|
2437
2688
|
test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
|
|
2438
2689
|
const r = rig(on, $)
|
|
@@ -2553,7 +2804,7 @@ for (const surface of SURFACES) {
|
|
|
2553
2804
|
expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
|
|
2554
2805
|
expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
|
|
2555
2806
|
expect(await textOf(ui, 'map-mark-198')).toBe('● ')
|
|
2556
|
-
expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color:
|
|
2807
|
+
expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { bold: true, color: '#bb9af7' } })
|
|
2557
2808
|
expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
|
|
2558
2809
|
expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
|
|
2559
2810
|
expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
|
|
@@ -278,6 +278,12 @@ test('groupTickets lists a needs-you ticket once, under Needs you, ascending, wh
|
|
|
278
278
|
expect(groupTickets([t(1, 'frontier', { isNeedsYou: false })]).needsYou).toEqual([])
|
|
279
279
|
})
|
|
280
280
|
|
|
281
|
+
test('failureReason blames the folder, not gh, when the checkout is gone', () => {
|
|
282
|
+
const error = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })
|
|
283
|
+
expect(failureReason({ error, missingRoot: '/work/r-t5' })).toBe('checkout not found: /work/r-t5')
|
|
284
|
+
expect(failureReason({ error })).toBe('gh not installed')
|
|
285
|
+
})
|
|
286
|
+
|
|
281
287
|
test('failureReason names one short cause', () => {
|
|
282
288
|
expect(failureReason({ error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) })).toBe('gh not installed')
|
|
283
289
|
expect(failureReason({ error: new Error('spawn gh ENOENT') })).toBe('gh not installed')
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { expect, test } from 'claude-code/testing'
|
|
2
2
|
|
|
3
|
-
import { GH_ISSUE_WRITE } from '../hooks/mod/map-pane'
|
|
3
|
+
import { GH_ISSUE_WRITE, isTrackerWrite } from '../hooks/mod/map-pane'
|
|
4
|
+
|
|
5
|
+
// A heredoc that writes a body file, as the model usually runs before a tracker write.
|
|
6
|
+
const HEREDOC = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
|
|
4
7
|
|
|
5
8
|
// Bash commands the pane refreshes on (a write to the tracker) and ones it must ignore.
|
|
6
9
|
const WRITES = [
|
|
@@ -25,6 +28,13 @@ const WRITES = [
|
|
|
25
28
|
"gh api -X PUT 'repos/o/r/issues/5/lock'",
|
|
26
29
|
'gh api repos/o/r/issues/5 --input body.json',
|
|
27
30
|
'node /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x',
|
|
31
|
+
// multi-line: a heredoc before the write, or a line on either side of it
|
|
32
|
+
`${HEREDOC}\ncd /repo && gh issue edit 325 --title "t"`,
|
|
33
|
+
`${HEREDOC}\ngh issue create --body-file /tmp/x.md`,
|
|
34
|
+
`${HEREDOC}\ngh issue comment 5 --body-file /tmp/x.md`,
|
|
35
|
+
'echo hi\ngh issue close 5',
|
|
36
|
+
'gh issue close 5\necho hi',
|
|
37
|
+
`${HEREDOC}\nnode /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x`,
|
|
28
38
|
]
|
|
29
39
|
const READS = [
|
|
30
40
|
'gh issue list --state open',
|
|
@@ -40,6 +50,10 @@ const READS = [
|
|
|
40
50
|
'gh api repos/o/r/pulls -f state=open',
|
|
41
51
|
'git log && echo gh issue',
|
|
42
52
|
'gh api repos/o/r/issues/5 | jq . ; echo -f x',
|
|
53
|
+
// multi-line reads
|
|
54
|
+
`${HEREDOC}\ngh issue view 5`,
|
|
55
|
+
`${HEREDOC}\ngh issue list --state open`,
|
|
56
|
+
'cd /repo\ngh api repos/o/r/issues/5',
|
|
43
57
|
]
|
|
44
58
|
|
|
45
59
|
test('the write matcher takes the tracker writes of a Bash call', () => {
|
|
@@ -49,3 +63,45 @@ test('the write matcher takes the tracker writes of a Bash call', () => {
|
|
|
49
63
|
test('the write matcher leaves reads alone', () => {
|
|
50
64
|
for (const command of READS) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, false])
|
|
51
65
|
})
|
|
66
|
+
|
|
67
|
+
// A heredoc body is data, not commands: [command, is it a tracker write]. `\t` is a tab.
|
|
68
|
+
const HEREDOCS: Array<[string, boolean]> = [
|
|
69
|
+
['cat >f <<EOF\ngh issue close 5\nEOF', false],
|
|
70
|
+
["cat >f <<'EOF'\ngh issue close 5\nEOF", false],
|
|
71
|
+
['cat >f <<"EOF"\ngh issue close 5\nEOF', false],
|
|
72
|
+
['cat >f <<-EOF\n\tgh issue close 5\n\tEOF', false],
|
|
73
|
+
['cat <<A >a; cat <<B >b\ngh issue close 1\nA\ngh issue edit 2\nB', false],
|
|
74
|
+
['cat <<A >a; cat <<B >b\nx\nA\ny\nB\ngh issue close 3', true],
|
|
75
|
+
['gh issue create --body-file - <<EOF\nbody\nEOF', true],
|
|
76
|
+
['gh issue comment 5 -F - <<EOF\ngh issue close 9\nEOF', true],
|
|
77
|
+
["cat <<'EOF' >x\nb\nEOF\ngh issue close 5", true],
|
|
78
|
+
["cat <<'EOF' >x\nEOF2\ngh issue close 5\n EOF\nEOF", false],
|
|
79
|
+
["cat <<'EOF' >x\ngh issue close 5", false],
|
|
80
|
+
['cat <<-EOF >x\nb\n EOF\ngh issue close 5\nEOF', false],
|
|
81
|
+
["cat <<< 'x'\ngh issue close 5", true],
|
|
82
|
+
['echo $((1<<2))\ngh issue close 5', true],
|
|
83
|
+
['echo $((1<<2)); gh issue view 5', false],
|
|
84
|
+
["cat <<'EOF' >x\nnode trailhead-issue-body.js append\nEOF", false],
|
|
85
|
+
['echo "a <<EOF"\ngh issue close 5', true],
|
|
86
|
+
// a `<<` in a comment opens nothing, so the write after it stays
|
|
87
|
+
['# build body with <<EOF\ngh issue close 5', true],
|
|
88
|
+
['git commit -m "x" # see <<EOF\ngh issue close 5', true],
|
|
89
|
+
['# build body with <<EOF\ngh issue view 5', false],
|
|
90
|
+
['cat a#b <<EOF\ngh issue close 5\nEOF', false],
|
|
91
|
+
// an opener inside a multi-line quoted string opens nothing
|
|
92
|
+
['echo "a\ncat <<EOF\nb"\ngh issue close 5\nEOF', true],
|
|
93
|
+
['echo "a\ncat <<EOF\nb"\ngh issue view 5\nEOF', false],
|
|
94
|
+
// a `<<` inside a parameter expansion opens nothing
|
|
95
|
+
['echo ${x:-<<EOF}\ngh issue close 5\nEOF', true],
|
|
96
|
+
// an apostrophe in a heredoc body leaks no quote state into later lines
|
|
97
|
+
["cat <<EOF\nit's\nEOF\ncat <<B\ngh issue close 5\nB", false],
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
test('isTrackerWrite takes the writes and leaves the reads', () => {
|
|
101
|
+
for (const command of WRITES) expect([command, isTrackerWrite(command)]).toEqual([command, true])
|
|
102
|
+
for (const command of READS) expect([command, isTrackerWrite(command)]).toEqual([command, false])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('isTrackerWrite ignores a heredoc body and keeps a write around it', () => {
|
|
106
|
+
for (const [command, verdict] of HEREDOCS) expect([command, isTrackerWrite(command)]).toEqual([command, verdict])
|
|
107
|
+
})
|