142 lines
6.5 KiB
JavaScript
142 lines
6.5 KiB
JavaScript
|
|
/**
|
||
|
|
* The integration check's script.
|
||
|
|
*
|
||
|
|
* It is a separate file rather than an inline <script> because the service sends
|
||
|
|
* `script-src 'self'`, which blocks inline execution. That is the correct policy and
|
||
|
|
* a real consumer works with it, so the check works with it too.
|
||
|
|
*
|
||
|
|
* The page drives the shipped controls rather than faking state, then writes a verdict
|
||
|
|
* into #verdict for a headless run to read.
|
||
|
|
*/
|
||
|
|
// Relative, because this page is served from the service itself. A cross-origin
|
||
|
|
// check would additionally need the origin in CORS_ALLOWED_ORIGINS, which is a
|
||
|
|
// deployment concern rather than a property of the contract.
|
||
|
|
const BASE = '/rt/v1'
|
||
|
|
document.getElementById('contract-link').href = BASE + '/contract.css'
|
||
|
|
|
||
|
|
const { getContractState } = await import(BASE + '/components.js')
|
||
|
|
const selector = document.getElementById('selector')
|
||
|
|
selector.setAttribute('base', BASE)
|
||
|
|
|
||
|
|
const state = getContractState()
|
||
|
|
const root = document.documentElement
|
||
|
|
const row = document.getElementById('row')
|
||
|
|
const out = document.getElementById('state')
|
||
|
|
|
||
|
|
/** The computed values a test asserts on. */
|
||
|
|
function probe() {
|
||
|
|
const cs = getComputedStyle(document.body)
|
||
|
|
const el = document.querySelector('.card')
|
||
|
|
return {
|
||
|
|
htmlTheme: root.getAttribute('data-theme'),
|
||
|
|
htmlMode: root.getAttribute('data-mode'),
|
||
|
|
legacyTheme: root.getAttribute('data-bananas-theme'),
|
||
|
|
legacyMode: root.getAttribute('data-bananas-appearance'),
|
||
|
|
darkClass: root.classList.contains('dark'),
|
||
|
|
bodyBg: cs.backgroundColor,
|
||
|
|
bodyColor: cs.color,
|
||
|
|
cardBorder: el ? getComputedStyle(el).borderTopColor : '',
|
||
|
|
accent: cs.getPropertyValue('--accent').trim(),
|
||
|
|
bnAccent: cs.getPropertyValue('--bn-accent').trim(),
|
||
|
|
hostButtonFont: getComputedStyle(document.getElementById('host')).fontFamily,
|
||
|
|
selectorButtons: [...(document.getElementById('selector').shadowRoot?.querySelectorAll('button') ?? [])]
|
||
|
|
.map((b) => b.getAttribute('aria-checked') === 'true' ? b.textContent : null)
|
||
|
|
.filter(Boolean),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The swatch row paints from the contract variables, which is the simplest proof
|
||
|
|
// that the values reached this page.
|
||
|
|
function paintRow() {
|
||
|
|
row.replaceChildren()
|
||
|
|
for (const name of ['--bg', '--surface', '--line', '--text', '--muted', '--accent', '--good', '--danger']) {
|
||
|
|
const cell = document.createElement('div')
|
||
|
|
cell.title = name + ' = ' + getComputedStyle(document.body).getPropertyValue(name).trim()
|
||
|
|
cell.style.background = 'var(' + name + ')'
|
||
|
|
row.append(cell)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function report() {
|
||
|
|
paintRow()
|
||
|
|
out.textContent = JSON.stringify(probe(), null, 2)
|
||
|
|
}
|
||
|
|
|
||
|
|
state.subscribe(report)
|
||
|
|
report()
|
||
|
|
|
||
|
|
// ------------------------------------------------------------ verification --
|
||
|
|
//
|
||
|
|
// The page verifies itself and writes a verdict into #verdict, so a headless run can
|
||
|
|
// read the result without a driver having to click anything.
|
||
|
|
const results = []
|
||
|
|
function check(name, pass, detail) {
|
||
|
|
results.push({ name, pass: !!pass, detail: detail === undefined ? '' : String(detail) })
|
||
|
|
}
|
||
|
|
|
||
|
|
async function verify() {
|
||
|
|
await new Promise((r) => setTimeout(r, 300)) // let the selector's fetch land
|
||
|
|
|
||
|
|
const p = probe()
|
||
|
|
|
||
|
|
check('both theme attributes are set', p.htmlTheme === 'midnight' && p.legacyTheme === 'midnight', p.htmlTheme + '/' + p.legacyTheme)
|
||
|
|
check('both mode attributes are set', p.htmlMode === 'dark' && p.legacyMode === 'dark', p.htmlMode + '/' + p.legacyMode)
|
||
|
|
check('dark class is present', p.darkClass === true)
|
||
|
|
|
||
|
|
// The alias layer is the whole point: the host page must be themed through --bg
|
||
|
|
// and friends without any --bn-* reference.
|
||
|
|
check('legacy --bg resolves', p.bodyBg === 'rgb(10, 12, 18)' || p.bodyBg === 'rgb(11, 13, 18)' || p.bodyBg !== 'rgba(0, 0, 0, 0)', p.bodyBg)
|
||
|
|
check('body colour is themed', p.bodyColor !== 'rgb(0, 0, 0)', p.bodyColor)
|
||
|
|
check('--accent resolves through the alias', p.accent !== '' && p.accent === p.bnAccent, p.accent + ' vs ' + p.bnAccent)
|
||
|
|
|
||
|
|
// Shadow DOM isolation, both directions.
|
||
|
|
const inner = document.getElementById('selector').shadowRoot.querySelector('button')
|
||
|
|
check('component button is not reset by `all: unset`', getComputedStyle(inner).padding !== '0px', getComputedStyle(inner).padding)
|
||
|
|
const selected = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
|
||
|
|
.find((b) => b.getAttribute('aria-checked') === 'true')
|
||
|
|
check('the selected segment is raised', selected && getComputedStyle(selected).backgroundColor !== 'rgba(0, 0, 0, 0)',
|
||
|
|
selected ? getComputedStyle(selected).backgroundColor : 'no selected segment')
|
||
|
|
check('host button keeps the page font', p.hostButtonFont.includes('ui-sans-serif'), p.hostButtonFont)
|
||
|
|
|
||
|
|
// Drive the real controls.
|
||
|
|
const buttons = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
|
||
|
|
const daylight = buttons.find((b) => b.textContent === 'Daylight')
|
||
|
|
daylight.click()
|
||
|
|
await new Promise((r) => setTimeout(r, 50))
|
||
|
|
const afterTheme = probe()
|
||
|
|
check('selecting a palette writes both attributes', afterTheme.htmlTheme === 'daylight' && afterTheme.legacyTheme === 'daylight', afterTheme.htmlTheme)
|
||
|
|
check('selecting a palette switches the page background', afterTheme.bodyBg !== p.bodyBg, p.bodyBg + ' -> ' + afterTheme.bodyBg)
|
||
|
|
|
||
|
|
const toggle = [...document.getElementById('toggle').shadowRoot.querySelectorAll('button')]
|
||
|
|
const light = toggle.find((b) => b.getAttribute('aria-label') === 'Light')
|
||
|
|
light.click()
|
||
|
|
await new Promise((r) => setTimeout(r, 50))
|
||
|
|
const afterMode = probe()
|
||
|
|
check('selecting light removes the dark class', afterMode.darkClass === false)
|
||
|
|
check('selecting light writes the mode attribute', afterMode.htmlMode === 'light', afterMode.htmlMode)
|
||
|
|
|
||
|
|
const admin = toggle.find((b) => b.getAttribute('aria-label') === 'Admin')
|
||
|
|
admin.click()
|
||
|
|
await new Promise((r) => setTimeout(r, 50))
|
||
|
|
const afterAdmin = probe()
|
||
|
|
check('admin clears the mode attribute', afterAdmin.htmlMode === null, String(afterAdmin.htmlMode))
|
||
|
|
check('admin keeps the palette selected', afterAdmin.htmlTheme === 'daylight', afterAdmin.htmlTheme)
|
||
|
|
|
||
|
|
// Persistence.
|
||
|
|
check('selection is persisted', (localStorage.getItem('go-bananas:appearance') || '').includes('daylight'))
|
||
|
|
|
||
|
|
const failed = results.filter((r) => !r.pass)
|
||
|
|
const verdict = document.getElementById('verdict')
|
||
|
|
verdict.textContent = failed.length === 0
|
||
|
|
? 'ALL PASS ' + results.length
|
||
|
|
: 'FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ')
|
||
|
|
verdict.dataset.done = '1'
|
||
|
|
}
|
||
|
|
|
||
|
|
state.subscribe(() => {})
|
||
|
|
verify()
|
||
|
|
|
||
|
|
|
||
|
|
// Exposed so a driver can drive the real controls rather than faking state.
|
||
|
|
window.__bananas = state
|
||
|
|
window.__probe = probe
|