diff --git a/extension/src/popup/components/__tests__/sections-render.test.ts b/extension/src/popup/components/__tests__/sections-render.test.ts new file mode 100644 index 0000000..435d26f --- /dev/null +++ b/extension/src/popup/components/__tests__/sections-render.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { renderSections } from '../fields'; +import type { Item } from '../../../shared/types'; + +function itemWithSections(sections: Item['sections']): Item { + return { + id: 'aaaaaaaaaaaaaaaa', + title: 'test', + type: 'login', + tags: [], favorite: false, + created: 0, modified: 0, + core: { type: 'login' }, + sections, + attachments: [], + field_history: {}, + }; +} + +describe('renderSections', () => { + it('returns empty string when item has no sections', () => { + const html = renderSections(itemWithSections([]), 'login'); + expect(html).toBe(''); + }); + + it('skips sections with zero fields', () => { + const html = renderSections(itemWithSections([ + { name: 'empty', fields: [] }, + ]), 'login'); + expect(html).not.toContain('empty'); + }); + + it('renders a named section header + field rows', () => { + const html = renderSections(itemWithSections([ + { + name: 'recovery codes', + fields: [ + { id: 'f0000001', label: 'code 1', kind: 'text', + value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('recovery codes'); + expect(html).toContain('code 1'); + expect(html).toContain('abc-123'); + }); + + it('renders concealed password fields with unique ids', () => { + const html = renderSections(itemWithSections([ + { + name: 'backup', + fields: [ + { id: 'f0000002', label: 'pin', kind: 'password', + value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('data-revealed="false"'); + expect(html).not.toMatch(/>hunter2 { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000003', label: 'extra', kind: 'text', + value: { kind: 'text', value: 'note' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('section-separator'); + expect(html).not.toContain('section-header'); + }); + + it('silently skips unsupported field kinds', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000004', label: 'link', kind: 'url' as any, + value: { kind: 'url', value: 'https://example.com' } as any, + hidden_by_default: false }, + { id: 'f0000005', label: 'note', kind: 'text', + value: { kind: 'text', value: 'kept' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).not.toContain('https://example.com'); + expect(html).toContain('kept'); + }); + + it('renders concealed fields for the concealed kind too', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000006', label: 'secret', kind: 'concealed', + value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('secret'); + expect(html).not.toMatch(/>shhh { + const visibleFields = section.fields.filter( + (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', + ); + if (visibleFields.length === 0) return; + + if (section.name) { + out += `
${escapeHtml(section.name)}
`; + } else { + out += `
`; + } + + visibleFields.forEach((field, fIdx) => { + if (field.value.kind === 'text') { + out += renderRow({ label: field.label, value: field.value.value, copyable: true }); + } else { + // password or concealed + out += renderConcealedRow({ + id: `${idPrefix}-s${sIdx}-f${fIdx}`, + label: field.label, + value: field.value.value, + }); + } + }); + }); + return out; +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index c5b8976..5ba02a9 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -508,3 +508,20 @@ textarea { .sig-block--green { border-left-color: #3fb950; } .sig-block--amber { border-left-color: #d29922; } .sig-block--red { border-left-color: #f85149; } + +/* --- custom-section rendering (β₂ slice 1) --- */ +.section-header { + margin-top: 14px; + margin-bottom: 4px; + padding-top: 10px; + border-top: 1px solid #21262d; + color: #8b949e; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.section-separator { + margin: 10px 0 4px; + border: 0; + border-top: 1px solid #21262d; +}