@cat-factory/app 0.6.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/app/components/board/ContextPicker.vue +367 -367
- package/app/components/gates/GateResultView.vue +90 -12
- package/app/components/layout/SideBar.vue +11 -0
- package/app/components/observability/StepMetricsBar.vue +102 -102
- package/app/components/observability/StepModelActivity.vue +49 -0
- package/app/components/panels/ObservabilityPanel.vue +1 -1
- package/app/components/panels/StepMetadataCard.vue +4 -16
- package/app/components/panels/StepRunMeta.vue +105 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
- package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -124
- package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
- package/app/components/testing/TestReportWindow.vue +17 -8
- package/app/composables/useBlockQueries.ts +154 -154
- package/app/composables/useContextLinking.ts +65 -65
- package/app/composables/useFrameResize.ts +54 -54
- package/app/pages/index.vue +2 -0
- package/app/stores/documents.ts +176 -176
- package/app/stores/services.ts +87 -87
- package/app/stores/tracker.ts +39 -27
- package/app/stores/ui.ts +12 -0
- package/app/types/documents.ts +104 -104
- package/app/types/domain.ts +5 -1
- package/app/types/execution.ts +18 -0
- package/app/types/github.ts +173 -173
- package/app/types/services.ts +27 -27
- package/app/types/tasks.ts +82 -82
- package/app/types/tracker.ts +27 -18
- package/app/utils/agentOutput.spec.ts +128 -128
- package/app/utils/agentOutput.ts +173 -173
- package/app/utils/observability.ts +52 -52
- package/package.json +6 -1
package/app/types/tasks.ts
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Task-source integration. Individual issues imported from external task
|
|
3
|
-
// trackers (Jira, …) can be attached to a board task as agent context. These
|
|
4
|
-
// mirror the `@cat-factory/contracts` task schemas; the abstraction is
|
|
5
|
-
// source-agnostic, keyed by `source`. Unlike document sources there is no
|
|
6
|
-
// plan/spawn — an issue is linked for context, never expanded into structure.
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
import type { CredentialField } from './documents'
|
|
10
|
-
|
|
11
|
-
/** The external task trackers cat-factory can link to. */
|
|
12
|
-
export type TaskSourceKind = 'jira' | 'github'
|
|
13
|
-
|
|
14
|
-
export type { CredentialField }
|
|
15
|
-
|
|
16
|
-
/** A source's self-description: drives the generic connect + import UI. */
|
|
17
|
-
export interface TaskSourceDescriptor {
|
|
18
|
-
source: TaskSourceKind
|
|
19
|
-
label: string
|
|
20
|
-
/** Lucide icon name for the source. */
|
|
21
|
-
icon: string
|
|
22
|
-
credentialFields: CredentialField[]
|
|
23
|
-
refLabel: string
|
|
24
|
-
refPlaceholder: string
|
|
25
|
-
/** Whether the source supports searching its catalogue by title/content. */
|
|
26
|
-
searchable?: boolean
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** A workspace's connection to a task source (never carries credentials). */
|
|
30
|
-
export interface TaskConnection {
|
|
31
|
-
source: TaskSourceKind
|
|
32
|
-
/** Human-friendly label for what we're connected to (site URL). */
|
|
33
|
-
label: string
|
|
34
|
-
/** When the connection was established (epoch ms). */
|
|
35
|
-
connectedAt: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** A single comment on an issue, with its body as lightweight Markdown. */
|
|
39
|
-
export interface TaskComment {
|
|
40
|
-
author: string
|
|
41
|
-
createdAt: string
|
|
42
|
-
body: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** An issue imported from a source into the workspace, as a structured record. */
|
|
46
|
-
export interface SourceTask {
|
|
47
|
-
source: TaskSourceKind
|
|
48
|
-
/** The source's canonical key for the issue (e.g. `PROJ-123`). */
|
|
49
|
-
externalId: string
|
|
50
|
-
title: string
|
|
51
|
-
url: string
|
|
52
|
-
/** Workflow status name, e.g. `In Progress`. */
|
|
53
|
-
status: string
|
|
54
|
-
/** Issue type name, e.g. `Bug`. */
|
|
55
|
-
type: string
|
|
56
|
-
/** Assignee display name, or null when unassigned. */
|
|
57
|
-
assignee: string | null
|
|
58
|
-
/** Priority name, or null when none. */
|
|
59
|
-
priority: string | null
|
|
60
|
-
labels: string[]
|
|
61
|
-
/** Issue description as lightweight Markdown. */
|
|
62
|
-
description: string
|
|
63
|
-
comments: TaskComment[]
|
|
64
|
-
/** Short plain-text preview of the issue. */
|
|
65
|
-
excerpt: string
|
|
66
|
-
/** The board block this issue is attached to as context, if any. */
|
|
67
|
-
linkedBlockId: string | null
|
|
68
|
-
syncedAt: number
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** A lean hit from searching a tracker's issues (not yet imported). */
|
|
72
|
-
export interface TaskSearchResult {
|
|
73
|
-
source: TaskSourceKind
|
|
74
|
-
/** The source's canonical key for the issue (re-usable as an import ref). */
|
|
75
|
-
externalId: string
|
|
76
|
-
title: string
|
|
77
|
-
url: string
|
|
78
|
-
/** Workflow status name, e.g. `In Progress` (may be empty). */
|
|
79
|
-
status: string
|
|
80
|
-
/** Short plain-text preview (may be empty). */
|
|
81
|
-
excerpt: string
|
|
82
|
-
}
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Task-source integration. Individual issues imported from external task
|
|
3
|
+
// trackers (Jira, …) can be attached to a board task as agent context. These
|
|
4
|
+
// mirror the `@cat-factory/contracts` task schemas; the abstraction is
|
|
5
|
+
// source-agnostic, keyed by `source`. Unlike document sources there is no
|
|
6
|
+
// plan/spawn — an issue is linked for context, never expanded into structure.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import type { CredentialField } from './documents'
|
|
10
|
+
|
|
11
|
+
/** The external task trackers cat-factory can link to. */
|
|
12
|
+
export type TaskSourceKind = 'jira' | 'github'
|
|
13
|
+
|
|
14
|
+
export type { CredentialField }
|
|
15
|
+
|
|
16
|
+
/** A source's self-description: drives the generic connect + import UI. */
|
|
17
|
+
export interface TaskSourceDescriptor {
|
|
18
|
+
source: TaskSourceKind
|
|
19
|
+
label: string
|
|
20
|
+
/** Lucide icon name for the source. */
|
|
21
|
+
icon: string
|
|
22
|
+
credentialFields: CredentialField[]
|
|
23
|
+
refLabel: string
|
|
24
|
+
refPlaceholder: string
|
|
25
|
+
/** Whether the source supports searching its catalogue by title/content. */
|
|
26
|
+
searchable?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A workspace's connection to a task source (never carries credentials). */
|
|
30
|
+
export interface TaskConnection {
|
|
31
|
+
source: TaskSourceKind
|
|
32
|
+
/** Human-friendly label for what we're connected to (site URL). */
|
|
33
|
+
label: string
|
|
34
|
+
/** When the connection was established (epoch ms). */
|
|
35
|
+
connectedAt: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A single comment on an issue, with its body as lightweight Markdown. */
|
|
39
|
+
export interface TaskComment {
|
|
40
|
+
author: string
|
|
41
|
+
createdAt: string
|
|
42
|
+
body: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An issue imported from a source into the workspace, as a structured record. */
|
|
46
|
+
export interface SourceTask {
|
|
47
|
+
source: TaskSourceKind
|
|
48
|
+
/** The source's canonical key for the issue (e.g. `PROJ-123`). */
|
|
49
|
+
externalId: string
|
|
50
|
+
title: string
|
|
51
|
+
url: string
|
|
52
|
+
/** Workflow status name, e.g. `In Progress`. */
|
|
53
|
+
status: string
|
|
54
|
+
/** Issue type name, e.g. `Bug`. */
|
|
55
|
+
type: string
|
|
56
|
+
/** Assignee display name, or null when unassigned. */
|
|
57
|
+
assignee: string | null
|
|
58
|
+
/** Priority name, or null when none. */
|
|
59
|
+
priority: string | null
|
|
60
|
+
labels: string[]
|
|
61
|
+
/** Issue description as lightweight Markdown. */
|
|
62
|
+
description: string
|
|
63
|
+
comments: TaskComment[]
|
|
64
|
+
/** Short plain-text preview of the issue. */
|
|
65
|
+
excerpt: string
|
|
66
|
+
/** The board block this issue is attached to as context, if any. */
|
|
67
|
+
linkedBlockId: string | null
|
|
68
|
+
syncedAt: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A lean hit from searching a tracker's issues (not yet imported). */
|
|
72
|
+
export interface TaskSearchResult {
|
|
73
|
+
source: TaskSourceKind
|
|
74
|
+
/** The source's canonical key for the issue (re-usable as an import ref). */
|
|
75
|
+
externalId: string
|
|
76
|
+
title: string
|
|
77
|
+
url: string
|
|
78
|
+
/** Workflow status name, e.g. `In Progress` (may be empty). */
|
|
79
|
+
status: string
|
|
80
|
+
/** Short plain-text preview (may be empty). */
|
|
81
|
+
excerpt: string
|
|
82
|
+
}
|
package/app/types/tracker.ts
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
|
-
// Issue-tracker selection shapes, mirroring `@cat-factory/contracts` (tracker.ts).
|
|
2
|
-
// A workspace designates one tracker — GitHub Issues or Jira — where the tech-debt
|
|
3
|
-
// recurring pipeline files its ticket before implementation starts.
|
|
4
|
-
|
|
5
|
-
export type TrackerKind = 'github' | 'jira'
|
|
6
|
-
|
|
7
|
-
export interface TrackerSettings {
|
|
8
|
-
/** The selected tracker, or null when none is configured. */
|
|
9
|
-
tracker: TrackerKind | null
|
|
10
|
-
/** Jira project key new tickets are filed under (e.g. 'ENG'); null unless Jira. */
|
|
11
|
-
jiraProjectKey: string | null
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
// Issue-tracker selection shapes, mirroring `@cat-factory/contracts` (tracker.ts).
|
|
2
|
+
// A workspace designates one tracker — GitHub Issues or Jira — where the tech-debt
|
|
3
|
+
// recurring pipeline files its ticket before implementation starts.
|
|
4
|
+
|
|
5
|
+
export type TrackerKind = 'github' | 'jira'
|
|
6
|
+
|
|
7
|
+
export interface TrackerSettings {
|
|
8
|
+
/** The selected tracker, or null when none is configured. */
|
|
9
|
+
tracker: TrackerKind | null
|
|
10
|
+
/** Jira project key new tickets are filed under (e.g. 'ENG'); null unless Jira. */
|
|
11
|
+
jiraProjectKey: string | null
|
|
12
|
+
/** Writeback: comment on a task's linked issue when its PR opens. Per-task overridable. */
|
|
13
|
+
writebackCommentOnPrOpen: boolean
|
|
14
|
+
/** Writeback: comment + close a task's linked issue as resolved when its PR merges. */
|
|
15
|
+
writebackResolveOnMerge: boolean
|
|
16
|
+
updatedAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PutTrackerSettingsInput {
|
|
20
|
+
tracker: TrackerKind | null
|
|
21
|
+
jiraProjectKey?: string | null
|
|
22
|
+
writebackCommentOnPrOpen?: boolean
|
|
23
|
+
writebackResolveOnMerge?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per-task writeback override; absent ⇒ inherit the workspace setting. */
|
|
27
|
+
export type WritebackOverride = 'on' | 'off'
|
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
|
|
3
|
-
|
|
4
|
-
describe('parseOutputOutline', () => {
|
|
5
|
-
it('splits on headings and builds a ToC', () => {
|
|
6
|
-
const out = parseOutputOutline(
|
|
7
|
-
['# Overview', 'intro text', '', '## Findings', '- a', '- b'].join('\n'),
|
|
8
|
-
)
|
|
9
|
-
expect(out.hasToc).toBe(true)
|
|
10
|
-
expect(out.minDepth).toBe(1)
|
|
11
|
-
expect(out.sections.map((s) => s.title)).toEqual(['Overview', 'Findings'])
|
|
12
|
-
expect(out.sections[0]!.depth).toBe(1)
|
|
13
|
-
expect(out.sections[1]!.depth).toBe(2)
|
|
14
|
-
const findings = out.sections[1]!.bodyHtml
|
|
15
|
-
// top-level blocks now carry data-src-* anchors, so match the open tag loosely
|
|
16
|
-
expect(findings).toContain('<ul')
|
|
17
|
-
expect(findings).toContain('<li>a</li>')
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('keeps text before the first heading as an untitled preamble (no ToC entry)', () => {
|
|
21
|
-
const out = parseOutputOutline('loose intro\n\n## Section')
|
|
22
|
-
expect(out.sections[0]!.title).toBe('')
|
|
23
|
-
expect(out.sections[0]!.depth).toBe(0)
|
|
24
|
-
expect(out.sections[0]!.bodyHtml).toContain('loose intro')
|
|
25
|
-
expect(out.sections.filter((s) => s.depth > 0)).toHaveLength(1)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('reports no ToC when there are no headings', () => {
|
|
29
|
-
const out = parseOutputOutline('just a paragraph of prose')
|
|
30
|
-
expect(out.hasToc).toBe(false)
|
|
31
|
-
expect(out.sections).toHaveLength(1)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('gives every section a unique id even with duplicate titles', () => {
|
|
35
|
-
const out = parseOutputOutline('## Risks\na\n## Risks\nb')
|
|
36
|
-
const ids = out.sections.map((s) => s.id)
|
|
37
|
-
expect(new Set(ids).size).toBe(ids.length)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('captures fenced code verbatim without treating its lines as headings', () => {
|
|
41
|
-
const out = parseOutputOutline('## Code\n```ts\nconst x = 1\n# not a heading\n```')
|
|
42
|
-
expect(out.sections).toHaveLength(1)
|
|
43
|
-
const body = out.sections[0]!.bodyHtml
|
|
44
|
-
expect(body).toContain('<pre>')
|
|
45
|
-
expect(body).toContain('# not a heading')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('escapes raw HTML rather than injecting it (html: false)', () => {
|
|
49
|
-
const out = parseOutputOutline('## S\n<img src=x onerror=alert(1)>')
|
|
50
|
-
const body = out.sections[0]!.bodyHtml
|
|
51
|
-
expect(body).not.toContain('<img')
|
|
52
|
-
expect(body).toContain('<img')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('renders inline marks and decorates links to open safely in a new tab', () => {
|
|
56
|
-
const out = parseOutputOutline('## S\nsee **bold** and [site](https://example.com)')
|
|
57
|
-
const body = out.sections[0]!.bodyHtml
|
|
58
|
-
expect(body).toContain('<strong>bold</strong>')
|
|
59
|
-
expect(body).toContain('href="https://example.com"')
|
|
60
|
-
expect(body).toContain('target="_blank"')
|
|
61
|
-
expect(body).toContain('rel="noopener noreferrer"')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('does not create a link for javascript: URLs (markdown-it validateLink)', () => {
|
|
65
|
-
// validateLink rejects the scheme, so it stays inert plain text — never an <a>.
|
|
66
|
-
const out = parseOutputOutline('## S\n[x](javascript:alert(1))')
|
|
67
|
-
const body = out.sections[0]!.bodyHtml
|
|
68
|
-
expect(body).not.toContain('<a ')
|
|
69
|
-
expect(body).not.toContain('href')
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('tolerates empty / nullish input', () => {
|
|
73
|
-
expect(parseOutputOutline('').sections).toHaveLength(0)
|
|
74
|
-
expect(parseOutputOutline(undefined as unknown as string).sections).toHaveLength(0)
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
describe('sliceSource', () => {
|
|
79
|
-
it('returns the verbatim line range (0-based, end-exclusive)', () => {
|
|
80
|
-
const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
|
|
81
|
-
expect(sliceSource(text, 1, 3)).toBe('line1\nline2')
|
|
82
|
-
expect(sliceSource(text, 0, 1)).toBe('line0')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('tolerates nullish input', () => {
|
|
86
|
-
expect(sliceSource(undefined as unknown as string, 0, 1)).toBe('')
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
describe('source-line stamping (approval-mode block anchors)', () => {
|
|
91
|
-
// Find a rendered block by its text and round-trip its `data-src-*` range back
|
|
92
|
-
// through `sliceSource` against the ORIGINAL output — this is the contract the
|
|
93
|
-
// approval reader relies on to quote the agent's own markdown back to it.
|
|
94
|
-
const blockFor = (output: string, contains: string) => {
|
|
95
|
-
const { sections } = parseOutputOutline(output)
|
|
96
|
-
const host = document.createElement('div')
|
|
97
|
-
host.innerHTML = sections.map((s) => s.bodyHtml).join('\n')
|
|
98
|
-
const el = Array.from(host.querySelectorAll('[data-src-start]')).find((e) =>
|
|
99
|
-
(e.textContent ?? '').includes(contains),
|
|
100
|
-
)
|
|
101
|
-
if (!el) throw new Error(`no source-stamped block containing "${contains}"`)
|
|
102
|
-
return {
|
|
103
|
-
start: Number(el.getAttribute('data-src-start')),
|
|
104
|
-
end: Number(el.getAttribute('data-src-end')),
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
it('round-trips a paragraph block to its original source lines', () => {
|
|
109
|
-
const output = ['## Summary', '', 'First paragraph.', '', 'Second paragraph here.'].join('\n')
|
|
110
|
-
const { start, end } = blockFor(output, 'First paragraph.')
|
|
111
|
-
expect(sliceSource(output, start, end)).toBe('First paragraph.')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('round-trips a multi-line fenced code block verbatim', () => {
|
|
115
|
-
const code = ['```ts', 'const x = 1', 'const y = 2', '```'].join('\n')
|
|
116
|
-
const output = ['## Code', '', code].join('\n')
|
|
117
|
-
const { start, end } = blockFor(output, 'const x = 1')
|
|
118
|
-
expect(sliceSource(output, start, end)).toBe(code)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('stamps top-level blocks only (a comment targets a whole block, not a nested item)', () => {
|
|
122
|
-
const output = ['Intro paragraph.', '', '- item one', '- item two'].join('\n')
|
|
123
|
-
const { start, end } = blockFor(output, 'item one')
|
|
124
|
-
// The whole list is the top-level block, so the slice spans both items.
|
|
125
|
-
expect(sliceSource(output, start, end)).toContain('- item one')
|
|
126
|
-
expect(sliceSource(output, start, end)).toContain('- item two')
|
|
127
|
-
})
|
|
128
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
|
|
3
|
+
|
|
4
|
+
describe('parseOutputOutline', () => {
|
|
5
|
+
it('splits on headings and builds a ToC', () => {
|
|
6
|
+
const out = parseOutputOutline(
|
|
7
|
+
['# Overview', 'intro text', '', '## Findings', '- a', '- b'].join('\n'),
|
|
8
|
+
)
|
|
9
|
+
expect(out.hasToc).toBe(true)
|
|
10
|
+
expect(out.minDepth).toBe(1)
|
|
11
|
+
expect(out.sections.map((s) => s.title)).toEqual(['Overview', 'Findings'])
|
|
12
|
+
expect(out.sections[0]!.depth).toBe(1)
|
|
13
|
+
expect(out.sections[1]!.depth).toBe(2)
|
|
14
|
+
const findings = out.sections[1]!.bodyHtml
|
|
15
|
+
// top-level blocks now carry data-src-* anchors, so match the open tag loosely
|
|
16
|
+
expect(findings).toContain('<ul')
|
|
17
|
+
expect(findings).toContain('<li>a</li>')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps text before the first heading as an untitled preamble (no ToC entry)', () => {
|
|
21
|
+
const out = parseOutputOutline('loose intro\n\n## Section')
|
|
22
|
+
expect(out.sections[0]!.title).toBe('')
|
|
23
|
+
expect(out.sections[0]!.depth).toBe(0)
|
|
24
|
+
expect(out.sections[0]!.bodyHtml).toContain('loose intro')
|
|
25
|
+
expect(out.sections.filter((s) => s.depth > 0)).toHaveLength(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports no ToC when there are no headings', () => {
|
|
29
|
+
const out = parseOutputOutline('just a paragraph of prose')
|
|
30
|
+
expect(out.hasToc).toBe(false)
|
|
31
|
+
expect(out.sections).toHaveLength(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('gives every section a unique id even with duplicate titles', () => {
|
|
35
|
+
const out = parseOutputOutline('## Risks\na\n## Risks\nb')
|
|
36
|
+
const ids = out.sections.map((s) => s.id)
|
|
37
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('captures fenced code verbatim without treating its lines as headings', () => {
|
|
41
|
+
const out = parseOutputOutline('## Code\n```ts\nconst x = 1\n# not a heading\n```')
|
|
42
|
+
expect(out.sections).toHaveLength(1)
|
|
43
|
+
const body = out.sections[0]!.bodyHtml
|
|
44
|
+
expect(body).toContain('<pre>')
|
|
45
|
+
expect(body).toContain('# not a heading')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('escapes raw HTML rather than injecting it (html: false)', () => {
|
|
49
|
+
const out = parseOutputOutline('## S\n<img src=x onerror=alert(1)>')
|
|
50
|
+
const body = out.sections[0]!.bodyHtml
|
|
51
|
+
expect(body).not.toContain('<img')
|
|
52
|
+
expect(body).toContain('<img')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders inline marks and decorates links to open safely in a new tab', () => {
|
|
56
|
+
const out = parseOutputOutline('## S\nsee **bold** and [site](https://example.com)')
|
|
57
|
+
const body = out.sections[0]!.bodyHtml
|
|
58
|
+
expect(body).toContain('<strong>bold</strong>')
|
|
59
|
+
expect(body).toContain('href="https://example.com"')
|
|
60
|
+
expect(body).toContain('target="_blank"')
|
|
61
|
+
expect(body).toContain('rel="noopener noreferrer"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not create a link for javascript: URLs (markdown-it validateLink)', () => {
|
|
65
|
+
// validateLink rejects the scheme, so it stays inert plain text — never an <a>.
|
|
66
|
+
const out = parseOutputOutline('## S\n[x](javascript:alert(1))')
|
|
67
|
+
const body = out.sections[0]!.bodyHtml
|
|
68
|
+
expect(body).not.toContain('<a ')
|
|
69
|
+
expect(body).not.toContain('href')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('tolerates empty / nullish input', () => {
|
|
73
|
+
expect(parseOutputOutline('').sections).toHaveLength(0)
|
|
74
|
+
expect(parseOutputOutline(undefined as unknown as string).sections).toHaveLength(0)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('sliceSource', () => {
|
|
79
|
+
it('returns the verbatim line range (0-based, end-exclusive)', () => {
|
|
80
|
+
const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
|
|
81
|
+
expect(sliceSource(text, 1, 3)).toBe('line1\nline2')
|
|
82
|
+
expect(sliceSource(text, 0, 1)).toBe('line0')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('tolerates nullish input', () => {
|
|
86
|
+
expect(sliceSource(undefined as unknown as string, 0, 1)).toBe('')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('source-line stamping (approval-mode block anchors)', () => {
|
|
91
|
+
// Find a rendered block by its text and round-trip its `data-src-*` range back
|
|
92
|
+
// through `sliceSource` against the ORIGINAL output — this is the contract the
|
|
93
|
+
// approval reader relies on to quote the agent's own markdown back to it.
|
|
94
|
+
const blockFor = (output: string, contains: string) => {
|
|
95
|
+
const { sections } = parseOutputOutline(output)
|
|
96
|
+
const host = document.createElement('div')
|
|
97
|
+
host.innerHTML = sections.map((s) => s.bodyHtml).join('\n')
|
|
98
|
+
const el = Array.from(host.querySelectorAll('[data-src-start]')).find((e) =>
|
|
99
|
+
(e.textContent ?? '').includes(contains),
|
|
100
|
+
)
|
|
101
|
+
if (!el) throw new Error(`no source-stamped block containing "${contains}"`)
|
|
102
|
+
return {
|
|
103
|
+
start: Number(el.getAttribute('data-src-start')),
|
|
104
|
+
end: Number(el.getAttribute('data-src-end')),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('round-trips a paragraph block to its original source lines', () => {
|
|
109
|
+
const output = ['## Summary', '', 'First paragraph.', '', 'Second paragraph here.'].join('\n')
|
|
110
|
+
const { start, end } = blockFor(output, 'First paragraph.')
|
|
111
|
+
expect(sliceSource(output, start, end)).toBe('First paragraph.')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('round-trips a multi-line fenced code block verbatim', () => {
|
|
115
|
+
const code = ['```ts', 'const x = 1', 'const y = 2', '```'].join('\n')
|
|
116
|
+
const output = ['## Code', '', code].join('\n')
|
|
117
|
+
const { start, end } = blockFor(output, 'const x = 1')
|
|
118
|
+
expect(sliceSource(output, start, end)).toBe(code)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('stamps top-level blocks only (a comment targets a whole block, not a nested item)', () => {
|
|
122
|
+
const output = ['Intro paragraph.', '', '- item one', '- item two'].join('\n')
|
|
123
|
+
const { start, end } = blockFor(output, 'item one')
|
|
124
|
+
// The whole list is the top-level block, so the slice spans both items.
|
|
125
|
+
expect(sliceSource(output, start, end)).toContain('- item one')
|
|
126
|
+
expect(sliceSource(output, start, end)).toContain('- item two')
|
|
127
|
+
})
|
|
128
|
+
})
|