Compare commits

...
Author SHA1 Message Date
james.maes 2752f35fa2 docs(#696): reconcile merged security qualifications
(cherry picked from commit b6106062bb09381407e92bdb55352175062941d2)
2026-10-04 17:38:18 -05:00
james.maes 723958fb32 test(acceptance): integrate session capture with selective navigation
(cherry picked from commit 6c5d6d92e14536ed472b2c8357a00c652718198b)
2026-10-04 17:38:18 -05:00
james.maes d803011cb5 test(acceptance): capture selected navigation request ownership
(cherry picked from commit bd7716fe8d)
2026-10-04 17:38:18 -05:00
james.maes ece4ac64a4 test(#1003): await complete chart label geometry
Keep the original strict overlap check on the same completed measurement sample. Refs QRun-IO/qqq#1003; historical rendering cause remains unproved.

(cherry picked from commit 4aa95cc363)
2026-10-04 17:38:18 -05:00
9 changed files with 296 additions and 30 deletions

No files matched your search

+2
View File
@@ -73,6 +73,8 @@ jobs:
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
# Fixed NAV023/NAV056 ownership capture only; original assertions and diagnostics remain strict.
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.browser == 'webkit' || matrix.browser == 'mobile,tablet') && 'navigation' || '0' }}
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
+5 -4
View File
@@ -13,10 +13,10 @@ This is a scoped security review, not a certification of ASVS Level 2 compliance
| NUI-04: dashboard headers | [#695](https://github.com/QRun-IO/qqq/issues/695) is closed for the Javalin static-export scope, with current-source provider tests and browser evidence. Standalone qualification is separate. |
| NUI-05: readable session cookie | [#733](https://github.com/QRun-IO/qqq/issues/733) is closed. Next resumes through the backend-owned cookie; the legacy endpoint correction is merged. Material's explicit readable-cookie compatibility mode remains supported. |
| NUI-06: previous-user browser state | The additional query-filter and saved-view-pointer retention finding is fixed in [#1006](https://github.com/QRun-IO/qqq/issues/1006), merged in frontend `f367756`. Global visual preferences are preserved. |
| NUI-09: standalone headers | Headers are implemented. [#734](https://github.com/QRun-IO/qqq/issues/734) remains open for integration. The metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) passed 48 scoped first-attempt browser cases across five profiles, with qualified bytes committed as frontend `d6254ce2` / backend `cf2a8bfc`. [Backend PR #1020](https://github.com/QRun-IO/qqq/pull/1020) merged as `1d16a70c`; frontend integration remains pending. The historical statement that the server sends no headers is no longer current. |
| NUI-09: standalone headers | [#734](https://github.com/QRun-IO/qqq/issues/734) and the metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) are closed after scoped qualification and integration. The paired implementation passed 48 standalone first-attempt cases across five profiles. [Frontend PR #40](https://github.com/QRun-IO/qqq-frontend-next/pull/40) merged as `138c3c6d`, tree `328bc322`, exactly matching the qualified combined candidate. Its separate full Javalin acceptance passed 3,015/3,015 first attempts against backend `1d16a70c`. HEAD/304, bounded metadata and Java-customizer limits remain documented in the closeout. |
| NUI-11: custom CSS | Current code treats application `customCss` as trusted developer configuration and inserts it with `textContent`. It is not filtered by the historical deny-list or sanitized by DOMPurify. CSP and application configuration remain separate controls. |
| Auth0 token verification | [#1008](https://github.com/QRun-IO/qqq/issues/1008) confirms issuer/audience validation and cache-boundary findings. [Backend PR #1012](https://github.com/QRun-IO/qqq/pull/1012) contains the correction; 164 focused tests passed on its combined backend tree. The exact-head CI passed and the correction merged into backend develop as `fc67393b`. An unconfigured audience remains supported; no audience enforcement is claimed in that configuration. |
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable delivery. The correction exists on the development line, but stable 4.0.0 remains affected. [PR #1018](https://github.com/QRun-IO/qqq/pull/1018) backports it; local 481-test qualification passed, while hosted Maven CI failed in the separate Mongo fixture scope tracked by #1019/#842. No stable publication is claimed. |
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable release delivery. [Backend PR #1021](https://github.com/QRun-IO/qqq/pull/1021) merged the qualified correction into stable main as `9168e434`; its merge tree equals the qualified candidate and the automatic post-merge reactor/sample/static gates passed. Published 4.0.0 remains affected; planned 4.0.1 is not published or artifact-qualified. PR #1018 is closed as superseded, not merged; its original Mongo failure is retained. The corrected 4.1 RC line remains distinct. |
The other scoped finding dispositions remain as reconciled in #696. NUI-10's
runtime-style exception remains deliberate; it does not allow inline scripts.
@@ -37,8 +37,9 @@ confirmed High/Critical exploits and must not be presented as universal complian
The 48-case standalone result covers its selected security scope and paired
candidate backend. Its analytics case uses an owned plugin through the real
application hook, not a vendor SDK or real account. It does not
replace full-browser acceptance, qualify later combined trees, or resolve the
separately tracked browser failures. Final 1.0 still requires owner visual
replace full-browser acceptance. The separate 3,015-case Javalin gate qualifies
the merged PR40 tree; neither result resolves separately tracked browser failures
or certifies a real provider or deployment. Final 1.0 still requires owner visual
acceptance and real-application soak.
## Historical review — September 25, 2026
+12 -5
View File
@@ -70,11 +70,18 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
leave the variable unset for a control run with the original handler tree. Native and
ordinary server logs can still contain session data; keep raw evidence private.
- **Document/XHR evidence (opt-in).** Set `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally to
attach `browser-boundary.json` through the diagnostics fixture; CI does not enable it.
Only `/app/person[/]`, `/app/person/2[/]`, and `/app/person.bulkEdit[/]` documents on
the configured UI/API origins are observed. Requests are exact method/path matches:
GET person metadata, session, person record 2 and person.bulkEdit process metadata;
POST querySavedView init, person query/count and person.bulkEdit init. The helper emits
attach `browser-boundary.json` through the diagnostics fixture. CI uses `navigation`
mode only for the exact NAV023 unique-key and NAV056 application-theme-override cases
on WebKit/tablet; other cases, including SEC043, do not opt in through that mode.
By default, only `/app/person[/]`, `/app/person/key[/]`, `/app/person/2[/]`, and
`/app/person.bulkEdit[/]` documents on configured UI/API origins are observed. To also
capture dashboard documents at exactly `/app` and `/app/`, set
`QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION=1` alongside `QQQ_ACCEPTANCE_BROWSER_TRACE=1`
locally. The session flag alone installs nothing and does not select additional tests;
other dashboard paths are not included. Requests are exact method/path matches:
GET person metadata, person record 2 and person.bulkEdit process metadata;
POST `/qqq/v1/manageSession`, querySavedView init, person query/count and person.bulkEdit init.
GET `/qqq/v1/manageSession` is excluded. The helper emits
fixed aliases, document UUID/sequence/clocks, native XHR send return/throw, status and
terminal events. It never emits URLs, query/hash values, headers, credentials, payloads,
error messages or stacks, and adds no request headers or interception. Disabled mode
@@ -6,6 +6,7 @@
*/
// The stock sample's widget dashboard and greetings app: every sample widget renders its real payload.
import { readChartLabelBoxes, type ChartLabelBox } from '../../support/chart-label-geometry'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady } from '../../support/touch'
import { chartTable, expectLoaded, isLargeLayout, widget, widgetPayload } from './widget-support'
@@ -217,7 +218,12 @@ test.describe('sample widgets dashboard', () => {
const labels = card.locator('.recharts-xAxis .recharts-cartesian-axis-tick-value')
await expect(labels).toHaveText(payload.chartData.labels)
for (const label of await labels.all()) await expect(label).not.toHaveAttribute('transform', /rotate/)
const boxes = await labels.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().toJSON()))
let boxes: ChartLabelBox[] = []
await expect.poll(async () => {
const sample = await labels.evaluateAll(readChartLabelBoxes, payload.chartData.labels)
boxes = sample ?? []
return sample !== null
}).toBe(true)
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
}
@@ -6,7 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { browserBoundaryInit, createBoundaryBuffer, installBrowserBoundaryCapture } from './browser-boundary-capture'
import { browserBoundaryInit, createBoundaryBuffer, installBrowserBoundaryCapture, shouldCaptureBrowserBoundary } from './browser-boundary-capture'
type EventRecord = Record<string, unknown>
@@ -69,6 +69,35 @@ function event(overrides: EventRecord = {}) {
}
describe('document-local boundary capture', () => {
it('captures the production session POST and excludes GET and other session paths', () => {
install()
const xhr = new NativeXHR()
const body = { accessToken: 'BODY_SECRET' }
expect(xhr.open('POST', '/qqq/v1/manageSession?token=QUERY_SECRET#HASH_SECRET')).toBe(xhr.openResult)
expect(xhr.send(body)).toBe(xhr.sendResult)
xhr.state(2, 200)
xhr.state(4, 200)
xhr.dispatchEvent(new Event('load'))
xhr.dispatchEvent(new Event('loadend'))
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend'])
expect(events.slice(1).every(row => row.endpoint === 'session' && row.method === 'POST')).toBe(true)
expect(xhr.calls[1]).toEqual({ operation: 'send', receiver: xhr, args: [body] })
const before = events.length
for (const [method, url] of [
['GET', '/qqq/v1/manageSession'], ['POST', '/qqq/v1/manageSession/'],
['POST', '/qqq/v1/manageSessionExtra'], ['POST', 'https://untrusted.invalid/qqq/v1/manageSession'],
]) {
const excluded = new NativeXHR()
excluded.open(method, url)
excluded.send(body)
excluded.state(4, 200)
excluded.dispatchEvent(new Event('loadend'))
expect(excluded.calls).toHaveLength(2)
}
expect(events).toHaveLength(before)
expect(JSON.stringify(events)).not.toMatch(/SECRET|accessToken|manageSession|untrusted/)
})
it('observes native arguments/results without reading credentials or payloads', () => {
install()
const xhr = new NativeXHR()
@@ -88,7 +117,7 @@ describe('document-local boundary capture', () => {
it('rethrows the identical native exception and never reads its message or name', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
const failure = new Error('MESSAGE_SECRET')
Object.defineProperty(failure, 'name', { get() { throw new Error('must not read name') } })
xhr.sendFailure = failure
@@ -104,7 +133,7 @@ describe('document-local boundary capture', () => {
it('preserves failed open and unknown URL objects without coercion or stale request identity', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
const url = { toString: vi.fn(() => 'OBJECT_SECRET') }
xhr.open('GET', url)
xhr.send('BODY_SECRET')
@@ -114,7 +143,7 @@ describe('document-local boundary capture', () => {
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
xhr.openFailure = failure
expect(() => xhr.open('GET', '/qqq/v1/manageSession')).toThrow(failure)
expect(() => xhr.open('POST', '/qqq/v1/manageSession')).toThrow(failure)
expect(events.at(-1)).toMatchObject({ event: 'open-throw', errorClass: 'DOMException' })
expect(JSON.stringify(events)).not.toMatch(/SECRET|PRIVATE/)
})
@@ -122,7 +151,7 @@ describe('document-local boundary capture', () => {
it('uses one listener set and distinguishes reuse after the prior loadend', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.state(2, 200)
xhr.state(2, 200)
@@ -159,7 +188,7 @@ describe('document-local boundary capture', () => {
sendResult = xhr.send(body)
})
const start = events.length
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(sendResult).toBe(xhr.sendResult)
expect(xhr.calls.at(-1)).toEqual({ operation: 'send', receiver: xhr, args: [body] })
const sendCount = scenario === 'reopen-send' ? 2 : 1
@@ -176,7 +205,7 @@ describe('document-local boundary capture', () => {
expect(after[0]).not.toHaveProperty('generation')
expect(after[0]).not.toHaveProperty('attempt')
// Once ambiguous, reusing that instance cannot silently regain trustworthy attribution.
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
expect(events.slice(start)).toEqual(after)
const next = new NativeXHR()
@@ -195,7 +224,7 @@ describe('document-local boundary capture', () => {
xhr.open('POST', '/qqq/v1/table/person/count')
xhr.send()
})
xhr.open('GET', initialURL)
xhr.open('POST', initialURL)
xhr.send()
xhr.state(4, 200)
const start = events.length
@@ -212,7 +241,7 @@ describe('document-local boundary capture', () => {
it('keeps terminal events on the active send when a repeated send throws', () => {
install()
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.sendFailure = new DOMException('PRIVATE', 'InvalidStateError')
expect(() => xhr.send()).toThrow(DOMException)
@@ -237,7 +266,7 @@ describe('document-local boundary capture', () => {
install()
const xhr = new NativeXHR()
vi.stubGlobal('__qqqBrowserBoundary', () => { throw new Error('OBSERVER_SECRET') })
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
vi.stubGlobal('__qqqBrowserBoundary', () => Promise.reject(new Error('OBSERVER_SECRET')))
expect(xhr.send()).toBe(xhr.sendResult)
await Promise.resolve()
@@ -255,7 +284,7 @@ describe('document-local boundary capture', () => {
install()
for (const url of ['/qqq/v1/table/person/PRIVATE', 'https://unrelated.invalid/qqq/v1/manageSession']) {
const xhr = new NativeXHR()
xhr.open('GET', url)
xhr.open('POST', url)
xhr.send('SECRET')
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
@@ -304,6 +333,19 @@ describe('document-local boundary capture', () => {
expect(events).toHaveLength(before)
})
it.each(['/application', '/app/other', '/app/person/1', '/app//', '/app/%2f'])('does not widen dashboard capture to %s', path => {
history.replaceState({}, '', path)
cleanup = browserBoundaryInit({ ...config, includeDashboardPaths: true })
const xhr = new NativeXHR()
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
window.dispatchEvent(new PageTransitionEvent('pageshow'))
expect(events).toEqual([])
expect(cleanup).toBeUndefined()
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
expect(xhr.calls).toHaveLength(2)
})
it('preserves an invalid native receiver and restores native descriptors on cleanup', () => {
install()
expect(() => NativeXHR.prototype.send.call(null as unknown as NativeXHR)).toThrow(TypeError)
@@ -315,7 +357,7 @@ describe('document-local boundary capture', () => {
it('bounds document output with an explicit overflow event', () => {
cleanup = browserBoundaryInit({ ...config, maxEvents: 3 })
const xhr = new NativeXHR()
xhr.open('GET', '/qqq/v1/manageSession')
xhr.open('POST', '/qqq/v1/manageSession')
xhr.send()
xhr.state(4, 200)
xhr.dispatchEvent(new Event('loadend'))
@@ -325,6 +367,40 @@ describe('document-local boundary capture', () => {
})
describe('Node capture boundary', () => {
it.each(['/app', '/app/'])('installs dashboard session capture only when opted in at %s', async path => {
history.replaceState({}, '', path)
install()
expect(events).toEqual([])
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
const capture = await installBrowserBoundaryCapture(context, {
enabled: true, origins: config.origins, includeDashboardPaths: true,
})
const binding = context.exposeBinding.mock.calls[0][1]
const source = { page: {}, frame: {} }
vi.stubGlobal('__qqqBrowserBoundary', (value: EventRecord) => binding(source, value))
const [init, options] = context.addInitScript.mock.calls[0]
cleanup = init(options)
const xhr = new NativeXHR()
xhr.open('POST', '/qqq/v1/manageSession?private=QUERY_SECRET')
xhr.send({ accessToken: 'BODY_SECRET' })
xhr.state(2, 200)
xhr.state(4, 200)
xhr.dispatchEvent(new Event('load'))
xhr.dispatchEvent(new Event('loadend'))
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: false }))
const snapshot = capture!.snapshot()
expect(snapshot).toMatchObject({ invalid: 0, dropped: 0 })
expect(snapshot.events.map(row => row.event)).toEqual([
'ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend', 'pageshow',
])
expect(snapshot.events.every(row => row.page === 'dashboard' && row.pageId === 'page-1' && row.frameId === 'frame-1')).toBe(true)
expect(snapshot.events[2]).toMatchObject({ endpoint: 'session', method: 'POST' })
snapshot.events[0].page = 'CHANGED'
expect(capture!.snapshot().events[0].page).toBe('dashboard')
expect(JSON.stringify(capture!.snapshot())).not.toMatch(/SECRET|accessToken|private=|manageSession/)
})
it('rejects malformed/private payloads and bounds records with explicit counters', () => {
const buffer = createBoundaryBuffer(2)
buffer.add(event(), 'page-1', 'frame-1')
@@ -339,10 +415,69 @@ describe('Node capture boundary', () => {
expect(JSON.stringify(buffer.snapshot())).not.toContain('SECRET')
})
it('retains copied bounded dashboard metadata and rejects raw paths and private fields', () => {
const buffer = createBoundaryBuffer(1)
const row = event({ page: 'dashboard', endpoint: 'session', method: 'POST' })
buffer.add(row, 'page-1', 'frame-1')
row.page = 'MUTATED_SECRET'
buffer.add(event({ page: '/app?token=PRIVATE' }), 'page-1', 'frame-1')
buffer.add(event({ page: 'dashboard', cookies: 'PRIVATE' }), 'page-1', 'frame-1')
buffer.add(event({ page: 'dashboard', seq: 2 }), 'page-1', 'frame-1')
expect(buffer.snapshot()).toMatchObject({ invalid: 2, dropped: 1, events: [{ page: 'dashboard', endpoint: 'session', method: 'POST' }] })
expect(JSON.stringify(buffer.snapshot())).not.toMatch(/SECRET|PRIVATE/)
})
it('disabled installer creates no bindings, init scripts or capture handle', async () => {
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
expect(await installBrowserBoundaryCapture(context, { enabled: false, origins: config.origins })).toBeUndefined()
expect(await installBrowserBoundaryCapture(context, { enabled: false, origins: config.origins, includeDashboardPaths: true })).toBeUndefined()
expect(context.exposeBinding).not.toHaveBeenCalled()
expect(context.addInitScript).not.toHaveBeenCalled()
})
})
describe('selective hosted navigation capture', () => {
const lookup = '[NAV-023] a unique-key value opens the matching record in place of the key URL @mobile'
const theme = '[NAV-056] appearance preferences explain an application theme override @mobile'
it.each(['webkit', 'tablet'])('captures only the two retained failure titles on %s', project => {
expect(shouldCaptureBrowserBoundary('navigation', project, lookup)).toBe(true)
expect(shouldCaptureBrowserBoundary('navigation', project, theme)).toBe(true)
expect(shouldCaptureBrowserBoundary('navigation', project, '[NAV-056] appearance preferences apply immediately and survive a reload @mobile')).toBe(false)
expect(shouldCaptureBrowserBoundary('navigation', project, `${lookup} extra`)).toBe(false)
})
it.each(['chromium', 'firefox', 'mobile'])('excludes %s while preserving explicit full capture', project => {
expect(shouldCaptureBrowserBoundary('navigation', project, lookup)).toBe(false)
expect(shouldCaptureBrowserBoundary('navigation', project, theme)).toBe(false)
expect(shouldCaptureBrowserBoundary('1', project, 'unrelated case')).toBe(true)
})
it.each([undefined, '', '0', 'true', 'navigation-extra'])('leaves capture disabled for mode %s', mode => {
expect(shouldCaptureBrowserBoundary(mode, 'webkit', lookup)).toBe(false)
})
it.each(['/app/person/key', '/app/person/key/'])('observes %s with a fixed alias and excludes key values', pathname => {
history.replaceState({}, '', `${pathname}?email=QUERY_SECRET#HASH_SECRET`)
install()
const xhr = new NativeXHR()
expect(xhr.open('GET', '/qqq/v1/metaData/table/person?token=API_SECRET')).toBe(xhr.openResult)
expect(xhr.send()).toBe(xhr.sendResult)
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return'])
expect(events.every(row => row.page === 'person-key')).toBe(true)
expect(events.at(-1)).toMatchObject({ endpoint: 'person-metadata', method: 'GET' })
const buffer = createBoundaryBuffer()
for (const row of events) buffer.add(row, 'page-1', 'frame-1')
expect(buffer.snapshot()).toMatchObject({ invalid: 0, dropped: 0 })
expect(buffer.snapshot().events).toHaveLength(4)
expect(JSON.stringify(buffer.snapshot())).not.toMatch(/SECRET|email|token|\/app|\/qqq/)
})
it('does not expand the page allow-list to other key lookups', () => {
history.replaceState({}, '', '/app/navDeepItem/key?code=SECRET')
install()
expect(cleanup).toBeUndefined()
expect(events).toEqual([])
expect(NativeXHR.prototype.open).toBe(originalOpen.value)
})
})
@@ -11,19 +11,32 @@ interface BrowserCaptureConfig {
origins: string[]
maxEvents: number
includeProcessPaths?: boolean
includeDashboardPaths?: boolean
}
/** Selects capture without changing the default-off or explicit full-capture switch. */
export function shouldCaptureBrowserBoundary(mode: string | undefined, project: string, title: string): boolean {
if (mode === '1') return true
if (mode !== 'navigation' || (project !== 'webkit' && project !== 'tablet')) return false
return title === '[NAV-023] a unique-key value opens the matching record in place of the key URL @mobile'
|| title === '[NAV-056] appearance preferences explain an application theme override @mobile'
}
type BoundaryEvent = Record<string, string | number | boolean>
/** Runs before application scripts. Keep all runtime dependencies inside this serializable function. */
export function browserBoundaryInit(config: BrowserCaptureConfig): (() => void) | undefined {
const pages: Record<string, string> = { '/app/person': 'person', '/app/person/': 'person' }
const pages: Record<string, string> = {
'/app/person': 'person', '/app/person/': 'person',
'/app/person/key': 'person-key', '/app/person/key/': 'person-key',
}
if (config.includeDashboardPaths) Object.assign(pages, { '/app': 'dashboard', '/app/': 'dashboard' })
const endpoints: Record<string, string> = {
'GET /qqq/v1/metaData/table/person': 'person-metadata',
'POST /qqq/v1/processes/querySavedView/init': 'saved-view-init',
'POST /qqq/v1/table/person/query': 'person-query',
'POST /qqq/v1/table/person/count': 'person-count',
'GET /qqq/v1/manageSession': 'session',
'POST /qqq/v1/manageSession': 'session',
}
if (config.includeProcessPaths) {
Object.assign(pages, { '/app/person/2': 'person-record', '/app/person/2/': 'person-record',
@@ -228,7 +241,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
const limit = Math.max(1, Math.min(maxEvents, 4096))
const numeric = ['seq', 'timeOrigin', 'now', 'xhr', 'generation', 'attempt', 'status', 'readyState']
const enums: Record<string, string[]> = {
page: ['person', 'person-record', 'person-bulk-edit'], phase: ['active', 'pagehide'],
page: ['person', 'person-key', 'person-record', 'person-bulk-edit', 'dashboard'], phase: ['active', 'pagehide'],
event: ['ready', 'pagehide', 'pageshow', 'DOMContentLoaded', 'load', 'visibilitychange', 'open-return', 'open-throw', 'send-enter', 'send-return', 'send-throw', 'headers', 'done', 'error', 'abort', 'timeout', 'loadend', 'overflow', 'xhr-limit', 'xhr-incomplete'],
reason: ['reentrant-open', 'send-during-open', 'reuse-before-loadend'],
endpoint: ['person-metadata', 'saved-view-init', 'person-query', 'person-count', 'session', 'person-record', 'process-metadata', 'process-init'],
@@ -266,7 +279,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
/** Installs nothing when disabled; snapshotting reads Node memory only, even after page destruction. */
export async function installBrowserBoundaryCapture(
context: Pick<BrowserContext, 'exposeBinding' | 'addInitScript'>,
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean },
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean; includeDashboardPaths?: boolean },
) {
if (!config.enabled) return undefined
const buffer = createBoundaryBuffer()
@@ -279,6 +292,6 @@ export async function installBrowserBoundaryCapture(
if (!frames.has(frame)) frames.set(frame, `frame-${++frameSequence}`)
buffer.add(value, pages.get(page)!, frames.get(frame)!)
})
await context.addInitScript(browserBoundaryInit, { origins: config.origins, maxEvents: 512, includeProcessPaths: config.includeProcessPaths })
await context.addInitScript(browserBoundaryInit, { origins: config.origins, maxEvents: 512, includeProcessPaths: config.includeProcessPaths, includeDashboardPaths: config.includeDashboardPaths })
return { snapshot: buffer.snapshot }
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { expect, it, vi } from 'vitest'
import { readChartLabelBoxes, type ChartLabelBox } from './chart-label-geometry'
const labels = ['January', 'February', 'March', 'April', 'May']
const complete = labels.map((_, index) => ({ left: 80 + index * 60, right: 122 + index * 60, width: 42, height: 13 }))
const zero = { left: 0, right: 0, width: 0, height: 0 }
function ticks(boxes: ChartLabelBox[], texts = labels) {
return boxes.map((box, index) => {
const node = document.createElementNS('http://www.w3.org/2000/svg', 'text')
node.textContent = texts[index]
// Explicit fields also represent invalid measurements without relying on DOMRect normalization.
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({ ...box, x: box.left, y: 0, top: 0, bottom: box.height, toJSON: () => box })
return node
})
}
it('keeps all expected text with zero geometry unready', () => {
const nodes = ticks(labels.map(() => zero))
expect(nodes.map(node => node.textContent)).toEqual(labels)
expect(readChartLabelBoxes(nodes, labels)).toBeNull()
})
it('keeps the retained one-real/four-zero geometry pattern unready', () => {
const nodes = ticks([{ left: 80.140625, right: 121.859375, width: 41.71875, height: 12.515625 }, zero, zero, zero, zero])
expect(nodes.map(node => node.textContent)).toEqual(labels)
expect(readChartLabelBoxes(nodes, labels)).toBeNull()
})
it('does not treat empty expected labels and no nodes as ready', () => {
expect(readChartLabelBoxes([], [])).toBeNull()
})
it('requires the complete expected label count', () => {
expect(readChartLabelBoxes(ticks(complete.slice(0, -1)), labels)).toBeNull()
})
it('requires expected text in the original order in the same geometry sample', () => {
expect(readChartLabelBoxes(ticks(complete, [...labels].reverse()), labels)).toBeNull()
})
it.each(['left', 'right', 'width', 'height'] as const)('rejects a non-finite %s', (field) => {
expect(readChartLabelBoxes(ticks([{ ...complete[0], [field]: Number.NaN }, ...complete.slice(1)]), labels)).toBeNull()
})
it.each(['width', 'height'] as const)('rejects zero %s even when the other dimensions are positive', (field) => {
expect(readChartLabelBoxes(ticks([{ ...complete[0], [field]: 0 }, ...complete.slice(1)]), labels)).toBeNull()
})
it('returns the complete measured sample for the original strict overlap check', () => {
const nodes = ticks(complete)
const boxes = readChartLabelBoxes(nodes, labels)!
expect(boxes).toEqual(complete)
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
for (const node of nodes) expect(node.getBoundingClientRect).toHaveBeenCalledTimes(1)
})
it('returns complete overlapping geometry immediately so the original assertion fails', () => {
const overlap = [{ ...complete[0], right: complete[1].left + 1, width: 61 }, ...complete.slice(1)]
const nodes = ticks(overlap)
const boxes = readChartLabelBoxes(nodes, labels)!
expect(boxes).toEqual(overlap)
expect(() => {
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
}).toThrow()
for (const node of nodes) expect(node.getBoundingClientRect).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,27 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
export interface ChartLabelBox {
left: number
right: number
width: number
height: number
}
/** Returns one complete rendered sample; overlap remains the caller's strict assertion. */
export function readChartLabelBoxes(nodes: Element[], expectedLabels: string[]): ChartLabelBox[] | null {
if (expectedLabels.length === 0 || nodes.length !== expectedLabels.length) return null
const boxes: ChartLabelBox[] = []
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]
if (node.textContent?.replace(/\s+/g, ' ').trim() !== expectedLabels[index].replace(/\s+/g, ' ').trim()) return null
const { left, right, width, height } = node.getBoundingClientRect()
if (![left, right, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null
boxes.push({ left, right, width, height })
}
return boxes
}
+3 -2
View File
@@ -7,7 +7,7 @@
import { randomUUID } from 'node:crypto'
import { test as base, expect, type APIRequestContext, type Page, type Request, type Response } from '@playwright/test'
import { installBrowserBoundaryCapture } from './browser-boundary-capture'
import { installBrowserBoundaryCapture, shouldCaptureBrowserBoundary } from './browser-boundary-capture'
import { interruptedLegacyFont, type FontRequestOutcome } from './font-diagnostics'
import { ACCEPTANCE_BACKEND_PORT, ACCEPTANCE_BACKEND_URL, ACCEPTANCE_UI_URL } from './ports'
@@ -97,9 +97,10 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
_browserBoundaryCapture: async ({ context }, provide, testInfo) => {
const capture = await installBrowserBoundaryCapture(context, {
enabled: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE === '1',
enabled: shouldCaptureBrowserBoundary(process.env.QQQ_ACCEPTANCE_BROWSER_TRACE, testInfo.project.name, testInfo.title),
origins: [new URL(ACCEPTANCE_UI_URL).origin, new URL(ACCEPTANCE_BACKEND_URL).origin],
includeProcessPaths: true,
includeDashboardPaths: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION === '1',
})
try {
await provide()