@startsimpli/ui 0.4.135 → 0.4.136
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/package.json +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/index.ts +62 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/index.ts +11 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What moved inside one sitting, field by field (bd startsim-g4rwo).
|
|
5
|
+
*
|
|
6
|
+
* ── THE THREE WAYS A FIELD CAN BE NAMED WITHOUT ITS VALUES ──────────────────
|
|
7
|
+
*
|
|
8
|
+
* All three arrive as `{}` and they mean different things, so rendering them
|
|
9
|
+
* the same way — or worse, rendering `{}` as "changed to nothing" — turns a
|
|
10
|
+
* permission boundary and a storage cap into apparent data loss. Each gets its
|
|
11
|
+
* own sentence:
|
|
12
|
+
*
|
|
13
|
+
* - WITHHELD (`redacted_fields` names it): you are not allowed to read this
|
|
14
|
+
* field, so you may not read its history either. The FACT of the edit is
|
|
15
|
+
* real and is shown; the values are not. This route serves every historical
|
|
16
|
+
* value a record ever held, which makes it a RICHER leak than the row read —
|
|
17
|
+
* so the gate is honoured here and SAID OUT LOUD, because silence reads as
|
|
18
|
+
* data loss and would send somebody looking for a bug.
|
|
19
|
+
* - OMITTED (`metadata.values_omitted`): the whole revision was over the byte
|
|
20
|
+
* cap, so every field is named and no values were stored. Nobody is being
|
|
21
|
+
* kept out; the values were never written.
|
|
22
|
+
* - TRUNCATED (`truncated: true` on the entry): the value was too large to
|
|
23
|
+
* store. The critical consequence is that A MISSING SIDE NOW SAYS NOTHING —
|
|
24
|
+
* everywhere else a missing `after` means the write DELETED the key — so the
|
|
25
|
+
* created/deleted inference is suppressed and the diff is REFUSED with its
|
|
26
|
+
* reason, rather than showing a deletion that never happened.
|
|
27
|
+
*/
|
|
28
|
+
import { useState } from 'react';
|
|
29
|
+
import { ChevronRight, EyeOff, Scissors } from 'lucide-react';
|
|
30
|
+
|
|
31
|
+
import { cn } from '../../lib/utils';
|
|
32
|
+
import { DiffViewer } from '../diff';
|
|
33
|
+
import {
|
|
34
|
+
canDiffFieldChange,
|
|
35
|
+
classifyFieldChange,
|
|
36
|
+
type FoldedFieldChange,
|
|
37
|
+
type RevisionSitting,
|
|
38
|
+
} from './revision-history';
|
|
39
|
+
import { unifiedTextDiff } from './text-diff';
|
|
40
|
+
|
|
41
|
+
/** A stored value as one line of text, for the side-by-side summary. Objects
|
|
42
|
+
* are JSON so a reader can at least see the shape; nothing is invented. */
|
|
43
|
+
function preview(value: unknown): string {
|
|
44
|
+
if (typeof value === 'string') return value;
|
|
45
|
+
if (value === null) return 'null';
|
|
46
|
+
if (value === undefined) return '';
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
} catch {
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
|
|
55
|
+
|
|
56
|
+
/** One side of a change. ABSENCE IS RENDERED AS ABSENCE — an em dash with
|
|
57
|
+
* screen-reader text — never as an empty box that looks like "" was stored. */
|
|
58
|
+
function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="min-w-0 flex-1">
|
|
61
|
+
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
62
|
+
{label}
|
|
63
|
+
</div>
|
|
64
|
+
{has ? (
|
|
65
|
+
<pre className={cn('rounded border border-border bg-muted/40 p-2 font-mono text-xs', CLAMP)}>
|
|
66
|
+
{preview(value)}
|
|
67
|
+
</pre>
|
|
68
|
+
) : (
|
|
69
|
+
<p className="rounded border border-dashed border-border p-2 text-xs text-muted-foreground">
|
|
70
|
+
<span aria-hidden="true">—</span>
|
|
71
|
+
<span className="sr-only">not set</span>
|
|
72
|
+
<span className="ms-1">not set</span>
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function FieldBody({ field, change }: { field: string; change: FoldedFieldChange }) {
|
|
80
|
+
const kind = classifyFieldChange(change);
|
|
81
|
+
|
|
82
|
+
if (kind === 'withheld') {
|
|
83
|
+
return (
|
|
84
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
85
|
+
<EyeOff className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
86
|
+
<span>
|
|
87
|
+
This field was changed, but <strong className="font-medium text-foreground">its
|
|
88
|
+
values are not shown to you</strong> — your access does not include reading{' '}
|
|
89
|
+
<code className="rounded bg-muted px-1">{field}</code> on this record. The edit itself
|
|
90
|
+
is real and is recorded.
|
|
91
|
+
</span>
|
|
92
|
+
</p>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (kind === 'omitted') {
|
|
97
|
+
return (
|
|
98
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
99
|
+
<Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
100
|
+
<span>
|
|
101
|
+
This field changed, but this revision was too large to store its values. The field
|
|
102
|
+
names were kept; the before and after were not written.
|
|
103
|
+
</span>
|
|
104
|
+
</p>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
{kind === 'truncated' && (
|
|
111
|
+
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
112
|
+
<Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
113
|
+
<span>
|
|
114
|
+
This value was too large to store in full, so what is shown may be a prefix and{' '}
|
|
115
|
+
<strong className="font-medium text-foreground">a missing side means nothing
|
|
116
|
+
here</strong>. No comparison is offered, because a comparison against a shortened
|
|
117
|
+
value would show a deletion that never happened.
|
|
118
|
+
</span>
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
<div className="flex flex-wrap gap-3 sm:flex-nowrap">
|
|
122
|
+
<Side label="Before" has={change.hasBefore} value={change.before} />
|
|
123
|
+
<Side label="After" has={change.hasAfter} value={change.after} />
|
|
124
|
+
</div>
|
|
125
|
+
{canDiffFieldChange(change) && (
|
|
126
|
+
<DiffViewer
|
|
127
|
+
diff={unifiedTextDiff(String(change.before), String(change.after), field)}
|
|
128
|
+
emptyLabel="No line-level change"
|
|
129
|
+
className="max-h-72 overflow-auto rounded border border-border"
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one-word verdict next to a field name. Neutral tokens throughout: a
|
|
137
|
+
* deletion is a thing a reviewer did on purpose far more often than it is an
|
|
138
|
+
* accident, so it is not painted as an error. */
|
|
139
|
+
const KIND_LABEL: Record<ReturnType<typeof classifyFieldChange>, string> = {
|
|
140
|
+
withheld: 'not shown to you',
|
|
141
|
+
omitted: 'values not stored',
|
|
142
|
+
truncated: 'too large to store',
|
|
143
|
+
created: 'set',
|
|
144
|
+
deleted: 'cleared',
|
|
145
|
+
updated: 'changed',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export interface RevisionFieldsProps {
|
|
149
|
+
sitting: RevisionSitting;
|
|
150
|
+
/** Map a wire field name to something a reader recognises. Identity by
|
|
151
|
+
* default — see `types.ts` on why this module never re-cases a name. */
|
|
152
|
+
fieldLabel?: (field: string) => string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function RevisionFields({ sitting, fieldLabel = (f) => f }: RevisionFieldsProps) {
|
|
156
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
157
|
+
const entries: Array<[string, FoldedFieldChange]> = [
|
|
158
|
+
...Object.keys(sitting.changed)
|
|
159
|
+
.sort()
|
|
160
|
+
.map((field) => [field, sitting.changed[field]!] as [string, FoldedFieldChange]),
|
|
161
|
+
// The name COLUMN, kept namespaced away from the data keys because a tenant
|
|
162
|
+
// may declare a data key literally called "name".
|
|
163
|
+
...(sitting.name ? ([['name (title)', sitting.name]] as Array<[string, FoldedFieldChange]>) : []),
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div className="space-y-2" data-testid="revision-fields">
|
|
168
|
+
{/* AN ATTEMPT IS NOT A CHANGE. A machine tried to write and
|
|
169
|
+
`human_edits.guard_machine_write` stopped it, so the record did not
|
|
170
|
+
move — and the whole value of this row is that the attempt is on the
|
|
171
|
+
record. It is listed separately from `changed` for that reason. */}
|
|
172
|
+
{sitting.preserved.length > 0 && (
|
|
173
|
+
<ul className="space-y-1" data-testid="revision-preserved">
|
|
174
|
+
{sitting.preserved.map((field) => (
|
|
175
|
+
<li key={field} className="text-xs text-muted-foreground">
|
|
176
|
+
<code className="rounded bg-muted px-1 text-foreground">{fieldLabel(field)}</code>{' '}
|
|
177
|
+
was not written — a person's edit was kept instead.
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{sitting.regenerate.length > 0 && (
|
|
184
|
+
<p className="text-xs text-muted-foreground" data-testid="revision-regenerate">
|
|
185
|
+
Claimed permission to re-author{' '}
|
|
186
|
+
{sitting.regenerate.map((f) => fieldLabel(f)).join(', ')}.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{entries.length > 0 && (
|
|
191
|
+
<ul className="divide-y divide-border rounded border border-border">
|
|
192
|
+
{entries.map(([field, change]) => {
|
|
193
|
+
const kind = classifyFieldChange(change);
|
|
194
|
+
const isOpen = open === field;
|
|
195
|
+
return (
|
|
196
|
+
<li key={field}>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
aria-expanded={isOpen}
|
|
200
|
+
onClick={() => setOpen(isOpen ? null : field)}
|
|
201
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
|
|
202
|
+
>
|
|
203
|
+
<ChevronRight
|
|
204
|
+
className={cn('h-3.5 w-3.5 shrink-0 transition-transform', isOpen && 'rotate-90')}
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
/>
|
|
207
|
+
<code className="min-w-0 truncate font-mono text-foreground">
|
|
208
|
+
{fieldLabel(field)}
|
|
209
|
+
</code>
|
|
210
|
+
<span className="ms-auto shrink-0 text-muted-foreground">{KIND_LABEL[kind]}</span>
|
|
211
|
+
</button>
|
|
212
|
+
{isOpen && (
|
|
213
|
+
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
|
214
|
+
<FieldBody field={field} change={change} />
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</li>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</ul>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rendered panel (bd startsim-g4rwo).
|
|
3
|
+
*
|
|
4
|
+
* The assertions that matter are the presentation rules, not the markup: a
|
|
5
|
+
* machine is always marked, a claimed person never is, `unknown` is CALM and
|
|
6
|
+
* explained once for the panel, a withheld field is named rather than shown as
|
|
7
|
+
* "changed to nothing", an attempt reads as an attempt, and the empty state
|
|
8
|
+
* never claims the record was never changed.
|
|
9
|
+
*/
|
|
10
|
+
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
|
11
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
12
|
+
|
|
13
|
+
import { ActorIdentity } from '../../ActorIdentity'
|
|
14
|
+
import { RecordHistoryPanel } from '../RecordHistoryPanel'
|
|
15
|
+
import type { EntityRevision, RevisionPage } from '../types'
|
|
16
|
+
|
|
17
|
+
function mount(ui: React.ReactElement) {
|
|
18
|
+
const client = new QueryClient({
|
|
19
|
+
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
|
20
|
+
})
|
|
21
|
+
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const T0 = Date.parse('2026-10-05T12:00:00.000Z')
|
|
25
|
+
const at = (ms: number) => new Date(T0 + ms).toISOString()
|
|
26
|
+
|
|
27
|
+
function rev(over: Partial<EntityRevision> & { version: number }): EntityRevision {
|
|
28
|
+
return {
|
|
29
|
+
id: `r${over.version}`,
|
|
30
|
+
action: 'updated',
|
|
31
|
+
source: 'patch',
|
|
32
|
+
precondition: 'none',
|
|
33
|
+
created_at: at(0),
|
|
34
|
+
actor_sub: 'u1',
|
|
35
|
+
actor_label: 'jurga@ogmc.example',
|
|
36
|
+
actor_kind: 'person',
|
|
37
|
+
actor_kind_source: 'claim',
|
|
38
|
+
metadata: { changed: { blog: { before: 'old', after: 'new' } } },
|
|
39
|
+
...over,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function page(results: EntityRevision[], over: Partial<RevisionPage> = {}): RevisionPage {
|
|
44
|
+
return { count: results.length, next: null, previous: null, results, ...over }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const clientFor = (p: RevisionPage) => ({ list: jest.fn().mockResolvedValue(p) })
|
|
48
|
+
|
|
49
|
+
describe('RecordHistoryPanel — the actor axis', () => {
|
|
50
|
+
it('ALWAYS marks a machine, and distinctly from a person', async () => {
|
|
51
|
+
mount(
|
|
52
|
+
<RecordHistoryPanel
|
|
53
|
+
client={clientFor(
|
|
54
|
+
page([
|
|
55
|
+
rev({
|
|
56
|
+
version: 2,
|
|
57
|
+
actor_sub: 'svc:n8n-ogmc',
|
|
58
|
+
actor_label: 'svc:n8n-ogmc',
|
|
59
|
+
actor_kind: 'machine',
|
|
60
|
+
actor_kind_source: 'inferred',
|
|
61
|
+
}),
|
|
62
|
+
rev({ version: 1, created_at: at(-600_000) }),
|
|
63
|
+
]),
|
|
64
|
+
)}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
const marks = await screen.findAllByTestId('actor-kind')
|
|
68
|
+
// Exactly ONE marker: the machine row. The claimed person gets none.
|
|
69
|
+
expect(marks).toHaveLength(1)
|
|
70
|
+
expect(marks[0]).toHaveTextContent('machine')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does NOT decorate a claimed person — the calm, common-good case', async () => {
|
|
74
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
75
|
+
await screen.findByTestId('record-history-panel')
|
|
76
|
+
expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('renders UNKNOWN with no per-row badge, and explains it ONCE for the panel', async () => {
|
|
80
|
+
mount(
|
|
81
|
+
<RecordHistoryPanel
|
|
82
|
+
client={clientFor(
|
|
83
|
+
page([
|
|
84
|
+
rev({ version: 2, actor_kind: 'unknown', actor_kind_source: 'inferred' }),
|
|
85
|
+
rev({
|
|
86
|
+
version: 1,
|
|
87
|
+
created_at: at(-600_000),
|
|
88
|
+
actor_kind: 'unknown',
|
|
89
|
+
actor_kind_source: 'inferred',
|
|
90
|
+
}),
|
|
91
|
+
]),
|
|
92
|
+
)}
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
const note = await screen.findByTestId('record-history-unknown-kind')
|
|
96
|
+
// ONE sentence for two unknown rows — not a badge per row, which is what
|
|
97
|
+
// would make the panel look broken for the whole rollout window.
|
|
98
|
+
expect(note).toBeInTheDocument()
|
|
99
|
+
expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
|
|
100
|
+
// And it must not read as a defect.
|
|
101
|
+
expect(note.textContent).toMatch(/not recorded/i)
|
|
102
|
+
expect(note.textContent).not.toMatch(/error|invalid|fail|broken|warning/i)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('still says WHO on an unknown row — only the KIND is unrecorded', async () => {
|
|
106
|
+
mount(
|
|
107
|
+
<RecordHistoryPanel
|
|
108
|
+
client={clientFor(page([rev({ version: 1, actor_kind: 'unknown' })]))}
|
|
109
|
+
/>,
|
|
110
|
+
)
|
|
111
|
+
expect(await screen.findByText('jurga@ogmc.example')).toBeInTheDocument()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does NOT show the unknown explanation when every row has a kind', async () => {
|
|
115
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
116
|
+
await screen.findByTestId('record-history-panel')
|
|
117
|
+
expect(screen.queryByTestId('record-history-unknown-kind')).not.toBeInTheDocument()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('marks a person whose kind was INFERRED, because that is a guess', async () => {
|
|
121
|
+
mount(
|
|
122
|
+
<RecordHistoryPanel
|
|
123
|
+
client={clientFor(page([rev({ version: 1, actor_kind_source: 'inferred' })]))}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
const mark = await screen.findByTestId('actor-kind')
|
|
127
|
+
expect(mark).toHaveTextContent(/inferred/)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('RecordHistoryPanel — redaction', () => {
|
|
132
|
+
it('names a withheld field at panel level instead of rendering it as empty', async () => {
|
|
133
|
+
mount(
|
|
134
|
+
<RecordHistoryPanel
|
|
135
|
+
client={clientFor(
|
|
136
|
+
page([rev({ version: 1, metadata: { changed: { body: {} } }, redacted_fields: ['body'] })], {
|
|
137
|
+
redacted_fields: ['body'],
|
|
138
|
+
}),
|
|
139
|
+
)}
|
|
140
|
+
/>,
|
|
141
|
+
)
|
|
142
|
+
const note = await screen.findByTestId('record-history-redacted')
|
|
143
|
+
expect(note).toHaveTextContent(/body/)
|
|
144
|
+
expect(note).toHaveTextContent(/not shown to you/i)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('says a withheld field was changed but its values are not shown', async () => {
|
|
148
|
+
mount(
|
|
149
|
+
<RecordHistoryPanel
|
|
150
|
+
client={clientFor(
|
|
151
|
+
page([rev({ version: 1, metadata: { changed: { body: {} } }, redacted_fields: ['body'] })]),
|
|
152
|
+
)}
|
|
153
|
+
/>,
|
|
154
|
+
)
|
|
155
|
+
const row = await screen.findByRole('button', { name: /body/ })
|
|
156
|
+
expect(row).toHaveTextContent(/not shown to you/i)
|
|
157
|
+
fireEvent.click(row)
|
|
158
|
+
expect(await screen.findByText(/its\s*values are not shown to you/i)).toBeInTheDocument()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reads the camelised envelope roll-up too', async () => {
|
|
162
|
+
mount(
|
|
163
|
+
<RecordHistoryPanel
|
|
164
|
+
client={clientFor(
|
|
165
|
+
page([rev({ version: 1, metadata: { changed: { body: {} } }, redactedFields: ['body'] })], {
|
|
166
|
+
redactedFields: ['body'],
|
|
167
|
+
}),
|
|
168
|
+
)}
|
|
169
|
+
/>,
|
|
170
|
+
)
|
|
171
|
+
expect(await screen.findByTestId('record-history-redacted')).toHaveTextContent(/body/)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('RecordHistoryPanel — attempts, truncation and completeness', () => {
|
|
176
|
+
it('an ATTEMPT reads as an attempt, never as a change', async () => {
|
|
177
|
+
mount(
|
|
178
|
+
<RecordHistoryPanel
|
|
179
|
+
client={clientFor(
|
|
180
|
+
page([
|
|
181
|
+
rev({
|
|
182
|
+
version: 1,
|
|
183
|
+
actor_sub: 'svc:n8n-ogmc',
|
|
184
|
+
actor_label: 'svc:n8n-ogmc',
|
|
185
|
+
actor_kind: 'machine',
|
|
186
|
+
actor_kind_source: 'inferred',
|
|
187
|
+
metadata: { changed: {}, preserved: ['team_verdict'] },
|
|
188
|
+
}),
|
|
189
|
+
]),
|
|
190
|
+
)}
|
|
191
|
+
/>,
|
|
192
|
+
)
|
|
193
|
+
expect(
|
|
194
|
+
await screen.findByText(/Tried to write team_verdict — held back/),
|
|
195
|
+
).toBeInTheDocument()
|
|
196
|
+
expect(screen.getByTestId('revision-preserved')).toHaveTextContent(/was not written/i)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('REFUSES to diff a truncated side and says why', async () => {
|
|
200
|
+
mount(
|
|
201
|
+
<RecordHistoryPanel
|
|
202
|
+
client={clientFor(
|
|
203
|
+
page([
|
|
204
|
+
rev({
|
|
205
|
+
version: 1,
|
|
206
|
+
metadata: { changed: { blog: { before: 'prefix only', after: 'other', truncated: true } } },
|
|
207
|
+
}),
|
|
208
|
+
]),
|
|
209
|
+
)}
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
const row = await screen.findByRole('button', { name: /blog/ })
|
|
213
|
+
expect(row).toHaveTextContent(/too large to store/i)
|
|
214
|
+
fireEvent.click(row)
|
|
215
|
+
expect(
|
|
216
|
+
await screen.findByText(/a missing side means nothing here/i),
|
|
217
|
+
).toBeInTheDocument()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('NEVER claims the trail is complete', async () => {
|
|
221
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
222
|
+
const note = await screen.findByTestId('record-history-incomplete')
|
|
223
|
+
expect(note).toHaveTextContent(/not recorded yet/i)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('an empty history says "no edits recorded", not "never changed"', async () => {
|
|
227
|
+
mount(<RecordHistoryPanel client={clientFor(page([]))} />)
|
|
228
|
+
expect(await screen.findByText('No edits recorded for this record')).toBeInTheDocument()
|
|
229
|
+
expect(screen.queryByText(/never been changed|never changed/i)).not.toBeInTheDocument()
|
|
230
|
+
// The caveat still stands on an empty panel — a quiet trail is the exact
|
|
231
|
+
// case a reader would otherwise over-read.
|
|
232
|
+
expect(screen.getByTestId('record-history-incomplete')).toBeInTheDocument()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('RecordHistoryPanel — valid HTML', () => {
|
|
237
|
+
it('never nests the field list inside a <p>', async () => {
|
|
238
|
+
// REGRESSION (bd startsim-g4rwo): ActivityTimeline wrapped `detail` in a
|
|
239
|
+
// <p>, and a <ul>/<div> inside a <p> is invalid HTML that React reparents
|
|
240
|
+
// during hydration — a hydration mismatch on EVERY row. tsc passed and
|
|
241
|
+
// jsdom rendered it happily; only a real browser reported it.
|
|
242
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
243
|
+
const fields = await screen.findByTestId('revision-fields')
|
|
244
|
+
expect(fields.closest('p')).toBeNull()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('RecordHistoryPanel — fetching', () => {
|
|
249
|
+
it('asks the server for pages, never for everything (rule 8)', async () => {
|
|
250
|
+
const client = clientFor(page([rev({ version: 1 })]))
|
|
251
|
+
mount(<RecordHistoryPanel client={client} pageSize={25} />)
|
|
252
|
+
await screen.findByTestId('record-history-panel')
|
|
253
|
+
expect(client.list).toHaveBeenCalledWith({ page: 1, pageSize: 25 })
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('folds across page boundaries — one sitting, not one per page', async () => {
|
|
257
|
+
const list = jest
|
|
258
|
+
.fn()
|
|
259
|
+
.mockResolvedValueOnce(
|
|
260
|
+
page([rev({ version: 2, created_at: at(2000) })], { count: 3, next: 'p2' }),
|
|
261
|
+
)
|
|
262
|
+
.mockResolvedValueOnce(page([rev({ version: 1, created_at: at(0) })], { count: 3 }))
|
|
263
|
+
mount(<RecordHistoryPanel client={{ list }} pageSize={1} />)
|
|
264
|
+
await screen.findByTestId('record-history-panel')
|
|
265
|
+
// jsdom has no IntersectionObserver, so the auto-load sentinel no-ops by
|
|
266
|
+
// design and the explicit button is the path under test.
|
|
267
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Load more' }))
|
|
268
|
+
await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
|
269
|
+
// Both rows are the same actor inside the window, so after the second page
|
|
270
|
+
// loads they are ONE entry reporting 2 saves.
|
|
271
|
+
await waitFor(() => expect(screen.getByText('2 saves')).toBeInTheDocument())
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('a 404 says the history is not available, never "no history"', async () => {
|
|
275
|
+
const list = jest.fn().mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 }))
|
|
276
|
+
mount(<RecordHistoryPanel client={{ list }} />)
|
|
277
|
+
expect(await screen.findByText('History is not available to you')).toBeInTheDocument()
|
|
278
|
+
expect(screen.queryByText('No edits recorded for this record')).not.toBeInTheDocument()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('any other failure offers a retry', async () => {
|
|
282
|
+
const list = jest.fn().mockRejectedValue(new Error('boom'))
|
|
283
|
+
mount(<RecordHistoryPanel client={{ list }} />)
|
|
284
|
+
expect(await screen.findByText('Could not load history')).toBeInTheDocument()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('ActorIdentity — the kind props are additive', () => {
|
|
289
|
+
it('renders exactly as before when no kind is passed', () => {
|
|
290
|
+
const { container } = render(<ActorIdentity actor={{ email: 'a@b.com' }} />)
|
|
291
|
+
expect(container.querySelector('[data-testid="actor-kind"]')).toBeNull()
|
|
292
|
+
expect(screen.getByTestId('actor-identity')).toHaveTextContent('a@b.com')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('still renders the machine marker when the actor has NO label at all', () => {
|
|
296
|
+
// A service token can carry no email; dropping the row would hide the one
|
|
297
|
+
// fact the actor axis exists to record.
|
|
298
|
+
render(<ActorIdentity actor={{}} kind="machine" kindSource="inferred" />)
|
|
299
|
+
expect(screen.getByTestId('actor-kind')).toHaveTextContent('machine')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('renders nothing at all when there is neither a label nor a kind worth marking', () => {
|
|
303
|
+
const { container } = render(<ActorIdentity actor={{}} kind="unknown" />)
|
|
304
|
+
expect(container).toBeEmptyDOMElement()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('carries the full truth in the title even when it draws no marker', () => {
|
|
308
|
+
render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" />)
|
|
309
|
+
const el = screen.getByTestId('actor-identity')
|
|
310
|
+
expect(el).toHaveAttribute('data-actor-kind', 'unknown')
|
|
311
|
+
})
|
|
312
|
+
})
|