Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions example/site-with-errors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ A small Jekyll site that demos the
and adds a [page whose images each intentionally trip one of the plugin's
rules](alt-text-errors.html).

The site also includes an
[advanced model-backed demo page](advanced-alt-text-errors.html). Its three
synthetic cases demonstrate keyword stuffing, plausible but contextually wrong
alt text, and an accurate control. None of those cases trips a deterministic
rule; enable `alt-text-quality` to evaluate them.

Use it for:

- **Manual testing** — build and serve the site, then point the scanner at it.
Expand Down Expand Up @@ -47,6 +53,7 @@ TEST_USERNAME=demo TEST_PASSWORD=demo bundle exec rackup
```

The errors page is then available at `/alt-text-errors/`.
The advanced page is available at `/advanced-alt-text-errors/`.

## Scan it with the plugin

Expand All @@ -63,3 +70,23 @@ The `example site-with-errors` test loads
[`alt-text-errors.html`](alt-text-errors.html), runs the real `alt-text-scan`
plugin against it, and asserts that every rule in the table above produces a
finding.

For a credential-free, machine-readable demo of both the baseline and the
model-backed finding contract:

```sh
npm run demo:verify
```

The model verdicts in that command are explicitly marked as mocked. They prove
the production rule-to-finding mapping without making network calls. To run the
real GitHub Models judge against only the synthetic advanced page, set
`GITHUB_MODELS_TOKEN` to a PAT with `models:read` and run:

```sh
npm run demo:live
```

If `AZURE_VISION_ENDPOINT` and `AZURE_VISION_KEY` are also set, the live command
automatically uses Azure Vision enrichment. Set `ALT_TEXT_JUDGE_MODE=copilot`
to force GitHub Models only.
28 changes: 28 additions & 0 deletions example/site-with-errors/advanced-alt-text-errors.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
layout: page
title: Advanced Alt Text Errors
permalink: /advanced-alt-text-errors/
---

<h1>Advanced alt text errors</h1>

<section>
<h2>Keyword stuffing</h2>
<p>This synthetic blue test graphic is used to verify the scanner plugin.</p>
<img
src="/assets/img/test-image.png"
alt="accessibility testing, web accessibility, best accessibility scanner 2026, WCAG tools, buy accessibility software"
/>
</section>

<section>
<h2>Plausible but contextually wrong</h2>
<p>The image below is the same blue test graphic and represents a successful test.</p>
<img src="/assets/img/test-image.png" alt="A red warning triangle marks a failed deployment." />
</section>

<section>
<h2>Correct control</h2>
<p>This final image shows the same graphic with an accurate alternative.</p>
<img src="/assets/img/test-image.png" alt="Blue square with the word test in white." />
</section>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"grade": "tsx --env-file=.env scripts/grade-alt-text-quality.ts"
"grade": "tsx --env-file=.env scripts/grade-alt-text-quality.ts",
"demo:verify": "tsx scripts/verify-demo.ts",
"demo:live": "tsx scripts/run-live-demo.ts"
},
"prettier": "@github/prettier-config",
"engines": {
Expand Down
80 changes: 80 additions & 0 deletions scripts/demo-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {readFile} from 'node:fs/promises'
import {createServer, type Server} from 'node:http'
import {fileURLToPath} from 'node:url'
import {chromium, type Browser, type Page} from 'playwright'

const fixtureRoot = fileURLToPath(new URL('../example/site-with-errors/', import.meta.url))

const routes = new Map([
['/baseline', {file: 'alt-text-errors.html', contentType: 'text/html; charset=utf-8'}],
['/advanced', {file: 'advanced-alt-text-errors.html', contentType: 'text/html; charset=utf-8'}],
['/assets/img/test-image.png', {file: 'assets/img/test-image.png', contentType: 'image/png'}],
])

function stripFrontMatter(contents: Buffer): Buffer {
const text = contents.toString('utf8')
return Buffer.from(text.replace(/^---\n[\s\S]*?\n---\n/, ''), 'utf8')
}

async function startServer(): Promise<{server: Server; origin: string}> {
const server = createServer(async (request, response) => {
const route = routes.get(request.url ?? '')
if (!route) {
response.writeHead(404).end()
return
}

try {
const rawContents = await readFile(new URL(route.file, `file://${fixtureRoot}/`))
const contents = route.contentType.startsWith('text/html') ? stripFrontMatter(rawContents) : rawContents
response.writeHead(200, {'Content-Type': route.contentType})
response.end(contents)
} catch (error) {
response.writeHead(500, {'Content-Type': 'text/plain; charset=utf-8'})
response.end(error instanceof Error ? error.message : String(error))

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.
}
})

await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})

const address = server.address()
if (!address || typeof address === 'string') throw new Error('Demo server did not bind to a TCP port.')
return {server, origin: `http://127.0.0.1:${address.port}`}
}

export type DemoHarness = {
page: Page
open(route: 'baseline' | 'advanced'): Promise<string>
close(): Promise<void>
}

export async function createDemoHarness(): Promise<DemoHarness> {
const {server, origin} = await startServer()
let browser: Browser
try {
browser = await chromium.launch()
} catch (error) {
server.close()
throw error
}
const page = await browser.newPage()

return {
page,
async open(route) {
const url = `${origin}/${route}`
await page.goto(url)
return url
},
async close() {
await page.close()
await browser.close()
await new Promise<void>((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()))
})
},
}
}
52 changes: 52 additions & 0 deletions scripts/run-live-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {readFile} from 'node:fs/promises'
import {emitFindings} from '../src/findings.js'
import {extractImageContext} from '../src/extract-image-context.js'
import {__setJudge, altTextQuality} from '../src/rules/alt-text-quality.js'
import type {Finding} from '../src/types.js'
import {createDemoHarness} from './demo-support.js'

if (!process.env['GITHUB_MODELS_TOKEN'] && !process.env['GITHUB_TOKEN']) {
throw new Error('Set GITHUB_MODELS_TOKEN to a PAT with models:read before running the live demo.')
}

const azureConfigured = Boolean(process.env['AZURE_VISION_ENDPOINT'] && process.env['AZURE_VISION_KEY'])
const requestedMode = process.env['ALT_TEXT_JUDGE_MODE']
const judgeMode =
requestedMode === 'copilot' || requestedMode === 'azure-augmented'
? requestedMode
: azureConfigured
? 'azure-augmented'
: 'copilot'

const harness = await createDemoHarness()
try {
const url = await harness.open('advanced')
const images = await extractImageContext(harness.page)
__setJudge(null)
const results = await altTextQuality.evaluate({url, images})
const findings: Finding[] = []
await emitFindings(altTextQuality, results, url, async finding => {
findings.push(finding)
})

const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as {
version: string
}
console.log(
JSON.stringify(
{
pluginVersion: packageJson.version,
evidence: 'live GitHub Models evaluation of the repository synthetic fixture',
judgeMode,
azureCredentialsConfigured: azureConfigured,
fixtureUrl: url,
findings,
},
null,
2,
),
)
} finally {
__setJudge(null)
await harness.close()
}
91 changes: 91 additions & 0 deletions scripts/verify-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {readFile} from 'node:fs/promises'
import altTextScan from '../index.js'
import {emitFindings} from '../src/findings.js'
import {__setJudge, altTextQuality} from '../src/rules/alt-text-quality.js'
import {extractImageContext} from '../src/extract-image-context.js'
import type {JudgeAltText, JudgeInput, JudgeVerdict} from '../src/judges/types.js'
import type {Finding} from '../src/types.js'
import {createDemoHarness} from './demo-support.js'

const keywordAlt =
'accessibility testing, web accessibility, best accessibility scanner 2026, WCAG tools, buy accessibility software'
const wrongAlt = 'A red warning triangle marks a failed deployment.'

class DemoJudge implements JudgeAltText {
async judge(input: JudgeInput): Promise<JudgeVerdict> {
if (input.alt === keywordAlt) {
return {
step: 4,
reasoning:
'The alt is a search-keyword list with commercial language rather than a description of the blue test graphic.',
verdict: 'needs-fix',
issue: 'keyword-stuffing',
confidence: 1,
suggestion: 'Blue square with the word test in white.',
}
}
if (input.alt === wrongAlt) {
return {
step: 4,
reasoning:
'The alt describes a red failure warning, but the image is a blue test graphic and the page identifies it as successful.',
verdict: 'needs-fix',
issue: 'inaccurate',
confidence: 1,
suggestion: 'Blue square with the word test in white.',
}
}
return {
step: 4,
reasoning: 'The alt accurately describes the synthetic graphic.',
verdict: 'ok',
issue: '',
confidence: 1,
suggestion: '',
}
}
}

const harness = await createDemoHarness()
try {
const baselineUrl = await harness.open('baseline')
const deterministicFindings: Finding[] = []
await altTextScan({
page: harness.page,
addFinding: async finding => {
deterministicFindings.push(finding)
},
})

const advancedUrl = await harness.open('advanced')
const images = await extractImageContext(harness.page)
__setJudge(new DemoJudge())
const results = await altTextQuality.evaluate({url: advancedUrl, images})
const mockedModelFindings: Finding[] = []
await emitFindings(altTextQuality, results, advancedUrl, async finding => {
mockedModelFindings.push(finding)
})

const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as {
version: string
}
console.log(
JSON.stringify(
{
pluginVersion: packageJson.version,
evidence: {
deterministic: 'real plugin scan; no credentials or model calls',
modelBacked: 'mocked judge; real extraction, rule mapping, and scanner Finding shape',
},
fixtureUrls: {baseline: baselineUrl, advanced: advancedUrl},
deterministicFindings,
mockedModelFindings,
},
null,
2,
),
)
} finally {
__setJudge(null)
await harness.close()
}
66 changes: 66 additions & 0 deletions tests/advanced-example.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {afterAll, beforeAll, describe, expect, it} from 'vitest'
import {emitFindings} from '../src/findings.js'
import {extractImageContext} from '../src/extract-image-context.js'
import type {JudgeAltText, JudgeInput, JudgeVerdict} from '../src/judges/types.js'
import {__setJudge, altTextQuality} from '../src/rules/alt-text-quality.js'
import type {Finding} from '../src/types.js'
import {createDemoHarness, type DemoHarness} from '../scripts/demo-support.js'

class FixtureJudge implements JudgeAltText {
async judge(input: JudgeInput): Promise<JudgeVerdict> {
if (input.alt.includes('best accessibility scanner')) {
return {
step: 4,
reasoning: 'Keyword list instead of an image description.',
verdict: 'needs-fix',
issue: 'keyword-stuffing',
confidence: 1,
suggestion: 'Blue square with the word test in white.',
}
}
if (input.alt.includes('red warning triangle')) {
return {
step: 4,
reasoning: 'The description does not match the image.',
verdict: 'needs-fix',
issue: 'inaccurate',
confidence: 1,
suggestion: 'Blue square with the word test in white.',
}
}
return {step: 4, reasoning: 'Accurate.', verdict: 'ok', issue: '', confidence: 1, suggestion: ''}
}
}

let harness: DemoHarness

beforeAll(async () => {
harness = await createDemoHarness()
})

afterAll(async () => {
__setJudge(null)
await harness.close()
})

describe('advanced example site', () => {
it('demonstrates model-only findings and generated remediation without credentials', async () => {
const url = await harness.open('advanced')
const images = await extractImageContext(harness.page)
expect(images).toHaveLength(3)

__setJudge(new FixtureJudge())
const results = await altTextQuality.evaluate({url, images})
const findings: Finding[] = []
await emitFindings(altTextQuality, results, url, async finding => {
findings.push(finding)
})

expect(findings).toHaveLength(2)
expect(findings.map(finding => finding.problemShort)).toEqual([
expect.stringContaining('keyword-stuffed'),
expect.stringContaining('inaccurate'),
])
expect(findings.every(finding => finding.solutionShort.includes('Blue square with the word test'))).toBe(true)
})
})