281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
AtspiSemanticCore,
|
|
type AtspiRawNode,
|
|
type AtspiTransport
|
|
} from './atspi-semantic-core'
|
|
|
|
const transportFor = (
|
|
nodes: Record<string, AtspiRawNode>,
|
|
children: Record<string, string[]>
|
|
): AtspiTransport => ({
|
|
readNode: vi.fn(async (reference) => {
|
|
const node = nodes[reference]
|
|
if (!node) {
|
|
throw new Error('missing node')
|
|
}
|
|
return node
|
|
}),
|
|
listChildren: vi.fn(async (reference) => children[reference] ?? []),
|
|
invoke: vi.fn(async () => true),
|
|
setText: vi.fn(async () => true),
|
|
select: vi.fn(async () => true),
|
|
focus: vi.fn(async () => true)
|
|
})
|
|
|
|
const node = (
|
|
nativeReference: string,
|
|
overrides: Partial<AtspiRawNode> = {}
|
|
): AtspiRawNode => ({
|
|
nativeReference,
|
|
owner: 'private-owner',
|
|
window: 'private-window',
|
|
role: 'push button',
|
|
name: 'Action',
|
|
states: ['enabled', 'enabled'],
|
|
actions: ['Click'],
|
|
geometry: { x: -10, y: 20, width: 100, height: 30 },
|
|
...overrides
|
|
})
|
|
|
|
describe('AtspiSemanticCore', () => {
|
|
it('traverses a cyclic tree safely and returns only normalized opaque data', async () => {
|
|
const transport = transportFor(
|
|
{
|
|
'/raw/root': node('/raw/root', {
|
|
role: 'Application',
|
|
name: ' Demo\u0000 app '
|
|
}),
|
|
'/raw/child': node('/raw/child')
|
|
},
|
|
{
|
|
'/raw/root': ['/raw/child'],
|
|
'/raw/child': ['/raw/root']
|
|
}
|
|
)
|
|
let token = 0
|
|
const core = new AtspiSemanticCore(transport, {
|
|
createToken: () => `opaque-${++token}`
|
|
})
|
|
const tree = await core.snapshot('/raw/root', new AbortController().signal)
|
|
const serialized = JSON.stringify(tree)
|
|
|
|
expect(tree.truncated).toBe(true)
|
|
expect(tree.root).toMatchObject({
|
|
ref: 'opaque-1',
|
|
role: 'application',
|
|
name: 'Demo app',
|
|
states: ['enabled'],
|
|
children: [
|
|
{
|
|
ref: 'opaque-2',
|
|
role: 'push-button',
|
|
actions: ['click'],
|
|
children: []
|
|
}
|
|
]
|
|
})
|
|
expect(serialized).not.toContain('/raw/')
|
|
expect(serialized).not.toContain('private-owner')
|
|
expect(serialized).not.toContain('private-window')
|
|
})
|
|
|
|
it('bounds node count, depth, children, text, and invalid geometry', async () => {
|
|
const transport = transportFor(
|
|
{
|
|
root: node('root', {
|
|
text: 'abcdef',
|
|
geometry: { x: 0, y: 0, width: -1, height: 10 }
|
|
}),
|
|
one: node('one'),
|
|
two: node('two')
|
|
},
|
|
{ root: ['one', 'two'] }
|
|
)
|
|
let token = 0
|
|
const core = new AtspiSemanticCore(transport, {
|
|
createToken: () => `ref-${++token}`,
|
|
maximumNodes: 2,
|
|
maximumChildrenPerNode: 1,
|
|
maximumTextLength: 3
|
|
})
|
|
const tree = await core.snapshot('root', new AbortController().signal)
|
|
|
|
expect(tree.truncated).toBe(true)
|
|
expect(tree.root.text).toBe('abc')
|
|
expect(tree.root.geometry).toBeUndefined()
|
|
expect(tree.root.children).toHaveLength(1)
|
|
})
|
|
|
|
it('redacts and refuses protected and password elements', async () => {
|
|
const transport = transportFor(
|
|
{
|
|
password: node('password', {
|
|
role: 'password text',
|
|
name: 'bank password',
|
|
text: 'hunter2',
|
|
value: 123,
|
|
password: true
|
|
})
|
|
},
|
|
{}
|
|
)
|
|
const core = new AtspiSemanticCore(transport, {
|
|
createToken: () => 'protected-ref'
|
|
})
|
|
const tree = await core.snapshot(
|
|
'password',
|
|
new AbortController().signal
|
|
)
|
|
|
|
expect(tree.root).toMatchObject({
|
|
ref: 'protected-ref',
|
|
name: '受保护内容',
|
|
actions: [],
|
|
protected: true
|
|
})
|
|
expect(tree.root.text).toBeUndefined()
|
|
expect(tree.root.value).toBeUndefined()
|
|
await expect(
|
|
core.focus('protected-ref', new AbortController().signal)
|
|
).rejects.toThrow('Protected')
|
|
expect(transport.focus).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('routes semantic operations and rejects stale or unsupported references', async () => {
|
|
let now = 10
|
|
let token = 0
|
|
const transport = transportFor(
|
|
{
|
|
first: node('first', { owner: 'owner-a', window: 'window-a' }),
|
|
second: node('second', { owner: 'owner-b', window: 'window-b' })
|
|
},
|
|
{}
|
|
)
|
|
const core = new AtspiSemanticCore(transport, {
|
|
now: () => now,
|
|
referenceTtlMs: 50,
|
|
createToken: () => `ref-${++token}`
|
|
})
|
|
await core.snapshot('first', new AbortController().signal)
|
|
await expect(
|
|
core.invoke('ref-1', 'CLICK', new AbortController().signal)
|
|
).resolves.toBe(true)
|
|
await expect(
|
|
core.setText('ref-1', 'new text', new AbortController().signal)
|
|
).resolves.toBe(true)
|
|
await expect(
|
|
core.select('ref-1', new AbortController().signal)
|
|
).resolves.toBe(true)
|
|
await expect(
|
|
core.invoke('ref-1', 'delete', new AbortController().signal)
|
|
).rejects.toThrow('unavailable')
|
|
|
|
core.invalidateWindow('owner-a', 'window-a')
|
|
await expect(
|
|
core.focus('ref-1', new AbortController().signal)
|
|
).rejects.toThrow('stale')
|
|
|
|
await core.snapshot('second', new AbortController().signal)
|
|
core.invalidateOwner('owner-b')
|
|
await expect(
|
|
core.focus('ref-2', new AbortController().signal)
|
|
).rejects.toThrow('stale')
|
|
|
|
await core.snapshot('first', new AbortController().signal)
|
|
now = 60
|
|
await expect(
|
|
core.focus('ref-3', new AbortController().signal)
|
|
).rejects.toThrow('stale')
|
|
|
|
now = 10
|
|
await core.snapshot('first', new AbortController().signal)
|
|
core.invalidateRegistryOwner()
|
|
await expect(
|
|
core.focus('ref-4', new AbortController().signal)
|
|
).rejects.toThrow('stale')
|
|
})
|
|
|
|
it('invalidates every reference from snapshot N when snapshot N+1 starts', async () => {
|
|
let token = 0
|
|
const core = new AtspiSemanticCore(
|
|
transportFor(
|
|
{
|
|
first: node('first'),
|
|
second: node('second')
|
|
},
|
|
{}
|
|
),
|
|
{ createToken: () => `generation-ref-${++token}` }
|
|
)
|
|
|
|
await core.snapshot('first', new AbortController().signal)
|
|
await core.snapshot('second', new AbortController().signal)
|
|
|
|
await expect(
|
|
core.focus('generation-ref-1', new AbortController().signal)
|
|
).rejects.toThrow('stale')
|
|
await expect(
|
|
core.focus('generation-ref-2', new AbortController().signal)
|
|
).resolves.toBe(true)
|
|
})
|
|
|
|
it('keeps the opaque reference registry hard bounded across snapshots', async () => {
|
|
let token = 0
|
|
const core = new AtspiSemanticCore(
|
|
transportFor(
|
|
{
|
|
root: node('root'),
|
|
child: node('child'),
|
|
extra: node('extra')
|
|
},
|
|
{ root: ['child', 'extra'] }
|
|
),
|
|
{
|
|
maximumReferences: 2,
|
|
createToken: () => `bounded-ref-${++token}`
|
|
}
|
|
)
|
|
|
|
for (let index = 0; index < 50; index += 1) {
|
|
const tree = await core.snapshot(
|
|
'root',
|
|
new AbortController().signal
|
|
)
|
|
expect(tree.truncated).toBe(true)
|
|
expect(
|
|
(
|
|
core as unknown as {
|
|
references: Map<string, unknown>
|
|
}
|
|
).references.size
|
|
).toBeLessThanOrEqual(2)
|
|
}
|
|
})
|
|
|
|
it('does not surface raw transport errors or accept duplicate opaque tokens', async () => {
|
|
const failedTransport = transportFor({ root: node('root') }, {})
|
|
failedTransport.readNode = vi.fn(async () => {
|
|
throw new Error('private bus path /org/a11y/atspi/accessible/123')
|
|
})
|
|
const failedCore = new AtspiSemanticCore(failedTransport)
|
|
await expect(
|
|
failedCore.snapshot('root', new AbortController().signal)
|
|
).rejects.toThrow('could not be read')
|
|
await expect(
|
|
failedCore.snapshot('root', new AbortController().signal)
|
|
).rejects.not.toThrow('/org/a11y/')
|
|
|
|
const duplicateCore = new AtspiSemanticCore(
|
|
transportFor(
|
|
{ root: node('root'), child: node('child') },
|
|
{ root: ['child'] }
|
|
),
|
|
{ createToken: () => 'duplicate-token' }
|
|
)
|
|
await expect(
|
|
duplicateCore.snapshot('root', new AbortController().signal)
|
|
).rejects.toThrow('reference creation failed')
|
|
})
|
|
})
|