This commit is contained in:
2026-06-16 06:18:42 +02:00
parent 3f5078841d
commit 66248c4736
39 changed files with 6699 additions and 94 deletions

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
})
afterEach(() => { cleanup(); localStorage.clear() })
function renderApp(): HTMLElement {
return render(<ProjectProvider><App /></ProjectProvider>).container
}
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
}
describe('Pass on to Agent', () => {
it('inserts "<note> <path:line>" via the agentPaste event', async () => {
const received: string[] = []
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
window.addEventListener('agentPaste', handler)
try {
const c = renderApp()
const treeRow = await waitFor(() => {
const r = find(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready')
return r
})
fireEvent.click(treeRow)
const ta = await waitFor(() => {
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
if (!t) throw new Error('editor not ready')
return t
})
fireEvent.contextMenu(ta)
const pass = await waitFor(() => {
const item = find(c, '.ctx-item', 'Pass on to Agent')
if (!item) throw new Error('menu not open')
return item
})
fireEvent.click(pass)
const input = await waitFor(() => {
const i = c.querySelector<HTMLInputElement>('.pass-input')
if (!i) throw new Error('popup not open')
return i
})
fireEvent.change(input, { target: { value: 'look here' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(received.length).toBeGreaterThan(0))
expect(received[0]).toBe('look here public/assets/store.js:1')
} finally {
window.removeEventListener('agentPaste', handler)
}
})
})
describe('Stage + commit', () => {
it('stages a file, commits with a message, and toasts', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = find(c, '.git-row', 'UserController.php')
if (!r) throw new Error('git not ready')
return r
})
const stageBtn = row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!
fireEvent.click(stageBtn)
// commit button reflects the staged count once a file is staged
await waitFor(() => expect(find(c, '.commit-btn', 'Commit')?.textContent).toMatch(/Commit\s*\d/))
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
fireEvent.change(msg, { target: { value: 'wire up balance' } })
const commitBtn = find(c, '.commit-btn', 'Commit') as HTMLButtonElement
expect(commitBtn.disabled).toBe(false)
fireEvent.click(commitBtn)
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
})
})

93
test/app.test.tsx Normal file
View File

@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
import React from 'react'
// The terminals use xterm + ResizeObserver, which don't belong in a jsdom unit
// test. Stub them — the rest of the workbench renders for real against the mock.
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
beforeAll(() => {
// jsdom gaps used by the tree/tab code.
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
})
afterEach(() => { cleanup(); localStorage.clear() })
function renderApp(): HTMLElement {
const { container } = render(
<ProjectProvider>
<App />
</ProjectProvider>,
)
return container
}
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text))
}
describe('App (mock data, jsdom)', () => {
it('renders the four-column workbench with the git change list', async () => {
const c = renderApp()
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
expect(c.querySelector('.workbench')).toBeTruthy()
expect(c.textContent).toContain('Source Control')
expect(c.textContent).toContain('Explorer')
// no file open yet
expect(c.textContent).toContain('No file open')
})
it('opens a changed file from the git panel into a diff tab', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = rowWithText(c, '.git-row', 'UserController.php')
if (!r) throw new Error('row not ready')
return r
})
fireEvent.click(row)
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
// changed file → diff toolbar with a status word
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
})
it('makes an edited buffer dirty (tab dot)', async () => {
const c = renderApp()
const treeRow = await waitFor(() => {
const r = rowWithText(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready')
return r
})
fireEvent.click(treeRow) // unchanged file → editable "code" mode
const ta = await waitFor(() => {
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
if (!t) throw new Error('editor not ready')
return t
})
expect(c.querySelector('.tab.dirtyclose')).toBeNull()
fireEvent.change(ta, { target: { value: '// edited\n' } })
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy())
})
it('searches file contents from the search modal', async () => {
const c = renderApp()
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
const searchBtn = rowWithText(c, '.tb-btn', 'Search')!
fireEvent.click(searchBtn)
const modal = await waitFor(() => {
const m = c.querySelector('.search-modal')
if (!m) throw new Error('modal not open')
return m as HTMLElement
})
const input = within(modal).getByPlaceholderText(/Search content/i)
fireEvent.change(input, { target: { value: 'balance' } })
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
})
})

54
test/config.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
describe('resolveConfig', () => {
it('creates config.default.json + theme.css and yields defaults for a fresh project', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
await resolveConfig(dir)
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
expect(def).toEqual(DEFAULTS)
const theme = await readFile(join(dir, '.helder/theme.css'), 'utf8')
expect(theme).toContain('--code-font')
expect(getConfig().ai.command).toBe('claude')
expect(getThemeCss()).toContain('Helder theme')
})
it('deep-merges a sparse config.json over defaults', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
await resolveConfig(dir)
await writeFile(join(dir, '.helder/config.json'),
JSON.stringify({ ai: { command: 'claude --model opus' }, editor: { tabSize: 2 } }))
await resolveConfig(dir)
const c = getConfig()
expect(c.ai.command).toBe('claude --model opus') // overridden
expect(c.ai.autoLaunch).toBe(true) // default kept
expect(c.editor.tabSize).toBe(2) // overridden
expect(c.editor.autoSave).toBe(false) // default kept
expect(c.git.confirmDiscard).toBe(true) // default kept
})
it('always regenerates config.default.json with full built-in defaults', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
await resolveConfig(dir)
await writeFile(join(dir, '.helder/config.json'), JSON.stringify({ ai: { command: 'x' } }))
await resolveConfig(dir)
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
expect(def.ai.command).toBe('claude')
})
it('does not overwrite an existing theme.css', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
await resolveConfig(dir)
await writeFile(join(dir, '.helder/theme.css'), ':root{--accent:#ff0000}')
await resolveConfig(dir)
expect(getThemeCss()).toContain('#ff0000')
})
})

71
test/diff.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { buildDiff, makeDiff } from '../src/renderer/src/diff'
describe('buildDiff', () => {
it('reports no changes for identical text', () => {
const d = buildDiff('a\nb\nc\n', 'a\nb\nc\n')
expect(d.add).toBe(0)
expect(d.del).toBe(0)
expect(d.rows.every((r) => r.sign === ' ')).toBe(true)
})
it('counts a single changed line as one add + one del', () => {
const d = buildDiff('a\nb\nc', 'a\nB\nc')
expect(d.add).toBe(1)
expect(d.del).toBe(1)
const signs = d.rows.map((r) => r.sign).join('')
expect(signs).toContain('-')
expect(signs).toContain('+')
})
it('treats an empty original as all additions (new file)', () => {
const d = buildDiff('', 'x\ny')
expect(d.add).toBe(2)
expect(d.del).toBe(0)
expect(d.left).toHaveLength(0)
expect(d.right).toHaveLength(2)
})
it('treats an empty updated as all deletions (deleted file)', () => {
const d = buildDiff('x\ny\nz', '')
expect(d.del).toBe(3)
expect(d.add).toBe(0)
expect(d.right).toHaveLength(0)
})
it('marks the changed line on both sides', () => {
const d = buildDiff('keep\nold\nkeep', 'keep\nnew\nkeep')
expect(d.left.find((l) => l.text === 'old')?.mark).toBe('del')
expect(d.right.find((l) => l.text === 'new')?.mark).toBe('add')
expect(d.left.find((l) => l.text === 'keep')?.mark).toBeNull()
})
it('aligns split rows: same lines pair, changes stack into the gap', () => {
const d = buildDiff('a\nold\nb', 'a\nnew\nb')
// every split row has at least one side
expect(d.split.every((r) => r.l || r.r)).toBe(true)
// the matched 'a' and 'b' lines pair on both sides
const paired = d.split.filter((r) => r.l && r.r && r.l.text === r.r.text)
expect(paired.map((r) => r.l!.text)).toEqual(['a', 'b'])
})
it('ignores a single trailing newline difference', () => {
const d = buildDiff('a\nb', 'a\nb\n')
expect(d.add).toBe(0)
expect(d.del).toBe(0)
})
})
describe('makeDiff', () => {
it('flags added / deleted and carries the text pair', () => {
const added = makeDiff('A', '', 'hi')
expect(added.added).toBe(true)
expect(added.deleted).toBe(false)
expect(added.original).toBe('')
expect(added.updated).toBe('hi')
const deleted = makeDiff('D', 'bye', '')
expect(deleted.deleted).toBe(true)
expect(deleted.added).toBe(false)
})
})

View File

@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
})
afterEach(() => { cleanup(); localStorage.clear() })
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
}
async function openChanged(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
const row = await waitFor(() => {
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
if (!r) throw new Error('git not ready')
return r
})
fireEvent.click(row)
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
return c
}
describe('Editor view modes', () => {
it('Updated mode is an editable buffer; Original is read-only', async () => {
const c = await openChanged()
fireEvent.click(find(c, '.seg button', 'Updated')!)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
fireEvent.click(find(c, '.seg button', 'Original')!)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
})
it('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
const c = await openChanged()
fireEvent.click(c.querySelector('.split-btn')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
expect(c.querySelector('.split-pane.left')).toBeTruthy()
expect(c.querySelector('.split-pane.right')).toBeTruthy()
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
})
})

78
test/fs.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { buildTreeFromPaths, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
async function fixture(): Promise<string> {
const d = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await mkdir(join(d, 'src'), { recursive: true })
await mkdir(join(d, 'node_modules/pkg'), { recursive: true })
await mkdir(join(d, '.git'), { recursive: true })
await writeFile(join(d, 'src', 'a.ts'), 'export const a = 1\n')
await writeFile(join(d, 'README.md'), '# hi\n')
await writeFile(join(d, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
await writeFile(join(d, '.git', 'HEAD'), 'ref: refs/heads/main\n')
await writeFile(join(d, 'logo.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x99]))
return d
}
describe('readTree', () => {
it('lists dirs before files, ignoring node_modules and .git', async () => {
dir = await fixture()
const tree = await readTree(dir)
const top = (tree.children || []).map((c) => c.name)
expect(top).not.toContain('node_modules')
expect(top).not.toContain('.git')
expect(top).toContain('src')
// dirs first
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
})
})
describe('readAll', () => {
it('indexes text files, skipping ignored dirs and binary files', async () => {
dir = await fixture()
const files = await readAll(dir)
const keys = Object.keys(files)
expect(keys).toContain('src/a.ts')
expect(keys).toContain('README.md')
expect(keys.some((k) => k.includes('node_modules'))).toBe(false)
expect(keys).not.toContain('logo.bin') // binary skipped
expect(files['src/a.ts']).toContain('export const a')
})
it('honors .gitignore in a repo (files.followGitignore default on)', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await simpleGit(dir).init()
await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
await writeFile(join(dir, 'secret.txt'), 'shh')
await writeFile(join(dir, 'keep.txt'), 'ok')
const keys = Object.keys(await readAll(dir))
expect(keys).toContain('keep.txt')
expect(keys).not.toContain('secret.txt')
})
})
describe('buildTreeFromPaths', () => {
it('nests paths with dirs before files, alphabetical', () => {
const t = buildTreeFromPaths('proj', ['src/b.ts', 'src/a.ts', 'README.md', 'src/util/x.ts'])
expect((t.children || []).map((c) => c.name)).toEqual(['src', 'README.md'])
const src = (t.children || []).find((c) => c.name === 'src')!
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
expect((src.children || []).find((c) => c.name === 'util')!.path).toBe('src/util')
})
})
describe('read/write round-trip', () => {
it('writes then reads the same content', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await writeProjectFile(dir, 'note.txt', 'hello world\n')
expect(await readProjectFile(dir, 'note.txt')).toBe('hello world\n')
})
})

25
test/fuzzy.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { fuzzy } from '../src/renderer/src/fuzzy'
describe('fuzzy', () => {
it('matches greedily from the left (first c, then a, then t)', () => {
expect(fuzzy('cat', 'concatenate')).toEqual([0, 4, 5])
})
it('matches a non-contiguous subsequence', () => {
expect(fuzzy('uc', 'UserController')).toEqual([0, 4])
})
it('is case-insensitive (skips the e to reach r)', () => {
expect(fuzzy('USR', 'user')).toEqual([0, 1, 3])
})
it('returns null when not a subsequence', () => {
expect(fuzzy('xyz', 'abc')).toBeNull()
expect(fuzzy('ca', 'abc')).toBeNull() // order matters
})
it('returns an empty index array for an empty query', () => {
expect(fuzzy('', 'anything')).toEqual([])
})
})

105
test/git.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { classify, discard, load, stage } from '../src/main/git-service'
describe('classify', () => {
it('reads the index code as staged, working code as unstaged', () => {
expect(classify('M', ' ')).toEqual({ letter: 'M', staged: true })
expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false })
expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true })
expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true })
expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true })
})
it('treats untracked as a new (A) unstaged file', () => {
expect(classify('?', '?')).toEqual({ letter: 'A', staged: false })
})
it('maps unmerged (U) to modified', () => {
expect(classify('U', 'U').letter).toBe('M')
})
})
describe('load (integration against a temp repo)', () => {
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
async function repo(): Promise<string> {
const d = await mkdtemp(join(tmpdir(), 'helder-git-'))
const g = simpleGit(d)
await g.init()
await g.addConfig('user.email', 't@example.com')
await g.addConfig('user.name', 'Test')
await g.addConfig('commit.gpgsign', 'false')
await writeFile(join(d, 'a.txt'), '1\n2\n3\n')
await g.add('.')
await g.commit('init')
return d
}
it('returns null for a non-repo directory', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-nogit-'))
expect(await load(dir)).toBeNull()
})
it('reports a modified file with HEAD-vs-worktree text', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nX\n3\n')
const res = await load(dir)
expect(res).not.toBeNull()
expect(res!.branch).toBeTruthy()
const a = res!.changes.find((c) => c.path === 'a.txt')
expect(a?.status).toBe('M')
expect(a?.staged).toBe(false)
expect(a?.original).toBe('1\n2\n3\n')
expect(a?.updated).toBe('1\nX\n3\n')
})
it('reports an untracked file as new (A), original empty', async () => {
dir = await repo()
await writeFile(join(dir, 'new.txt'), 'fresh\n')
const res = await load(dir)
const n = res!.changes.find((c) => c.path === 'new.txt')
expect(n?.status).toBe('A')
expect(n?.staged).toBe(false)
expect(n?.original).toBe('')
expect(n?.updated).toBe('fresh\n')
})
it('reflects staging', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
await stage(dir, ['a.txt'])
const res = await load(dir)
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
})
it('discard reverts a modified tracked file to HEAD', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')
await discard(dir, ['a.txt'])
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('1\n2\n3\n')
const res = await load(dir)
expect(res!.changes.find((c) => c.path === 'a.txt')).toBeUndefined()
})
it('discard removes a new (untracked) file from disk', async () => {
dir = await repo()
await writeFile(join(dir, 'new.txt'), 'fresh\n')
await discard(dir, ['new.txt'])
expect(existsSync(join(dir, 'new.txt'))).toBe(false)
})
it('discard removes a staged-new file', async () => {
dir = await repo()
await writeFile(join(dir, 'staged-new.txt'), 'x\n')
await stage(dir, ['staged-new.txt'])
await discard(dir, ['staged-new.txt'])
expect(existsSync(join(dir, 'staged-new.txt'))).toBe(false)
const res = await load(dir)
expect(res!.changes.find((c) => c.path === 'staged-new.txt')).toBeUndefined()
})
})

60
test/highlight.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { HL } from '../src/renderer/src/highlight'
describe('HL.ext', () => {
it('extracts a normal extension', () => {
expect(HL.ext('src/a/b.php')).toBe('php')
expect(HL.ext('x.TSX')).toBe('tsx')
})
it('treats dotfiles like .env specially', () => {
expect(HL.ext('.env')).toBe('env')
expect(HL.ext('config/.env.local')).toBe('env')
})
it('returns empty for no extension', () => {
expect(HL.ext('Makefile')).toBe('')
})
})
describe('HL.langFor / langLabel', () => {
it('maps known extensions to Prism languages', () => {
expect(HL.langFor('a.php')).toBe('php')
expect(HL.langFor('a.ts')).toBe('typescript')
expect(HL.langFor('a.py')).toBe('python')
expect(HL.langFor('a.unknownext')).toBeNull()
})
it('produces human labels', () => {
expect(HL.langLabel('a.php')).toBe('PHP')
expect(HL.langLabel('a.tsx')).toBe('TypeScript')
expect(HL.langLabel('Makefile')).toBe('Plain Text')
})
})
describe('HL.iconFor', () => {
it('uses name-specific icons', () => {
expect(HL.iconFor('composer.json').t).toBe('co')
expect(HL.iconFor('package.json').t).toBe('pk')
})
it('falls back to extension icons', () => {
expect(HL.iconFor('x.php').c).toBe('#a78bdb')
})
it('falls back to first two letters for unknown types', () => {
expect(HL.iconFor('weird.zzz').t).toBe('we')
})
})
describe('HL.escapeHtml', () => {
it('escapes html-significant characters', () => {
expect(HL.escapeHtml('<a> & </a>')).toBe('&lt;a&gt; &amp; &lt;/a&gt;')
})
})
describe('HL.hlText (Prism)', () => {
it('wraps php keywords in token spans (markup-templating loaded first)', () => {
const out = HL.hlText('<?php class A {}', 'php')
expect(out).toContain('token')
expect(out).toContain('class')
})
it('escapes when no grammar is available', () => {
expect(HL.hlText('<x>', null)).toBe('&lt;x&gt;')
})
})