diff --git a/.gitignore b/.gitignore index 275d9851..ebcabd95 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,7 @@ kayle/screenshot.png chrome-extension # build custom extension -build-extension.js \ No newline at end of file +build-extension.js + +# data dir +_data \ No newline at end of file diff --git a/README.md b/README.md index ee9020cb..606981a9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ const results = await autoKayle({ }); ``` +## Clips + +You can include base64 images with the audits to get a visual of the exact location of the issue. + +```ts +const results = await kayle({ + page, + browser, + runners: ["axe"], + includeWarnings: true, + origin: "https://www.drake.com", + waitUntil: "domcontentloaded", + allowImages: true, + clip: true, // get the clip cords to display in browser. Use clipDir or clip2Base64 to convert to image. + clipDir: "./_data/drake.com", // optional: directory to store the clip as an image. + clip2Base64: true, // optional: attach a base64 property of the clip +}); +``` + ## Runners `kayle` supports multiple test runners which return different results. The built-in test runners are: @@ -103,6 +122,12 @@ type RunnerConfig = { standard?: Standard; // stop test that go beyond time. timeout?: number; + // allow capturing the image visually to base64 + clip?: boolean; + // store clips to a directory must have allowImages set or CDP reset of intercepts + clipDir?: string; + // store a clip to base64 on the issue + clip2Base64?: boolean; // allow images to render. allowImages?: boolean; // the website url: include this even with static html to fetch assets correct. @@ -112,7 +137,7 @@ type RunnerConfig = { }; ``` -### Features +### Optional Features 1. adblock - You can enable Brave's adblock engine with [adblock-rs](https://github.com/brave/adblock-rust) by installing `npm i adblock-rs` to the project. This module needs to be manually installed and the env variable `KAYLE_ADBLOCK` needs to be set to `true`. @@ -134,6 +159,8 @@ type RunnerConfig = { 1. zh-CN ("Chinese-Simplified") 1. zh-TW ("Chinese-Traditional") +![Video of clips being stored of the issue to get visual feedback](https://user-images.githubusercontent.com/8095978/268726837-f362a490-b611-4acf-8cb6-104f58a0a6c7.gif) + ## Testing For the comparison between using `fast_htmlcs`, `fast_axecore`, and the metrics for the 3rd party `@axe-core/playwright`. diff --git a/kayle/lib/common.ts b/kayle/lib/common.ts index 46dad69e..658ad024 100644 --- a/kayle/lib/common.ts +++ b/kayle/lib/common.ts @@ -17,6 +17,15 @@ export type Issue = { runnerExtras: Record; recurrence: number; selector: string; + // the position on the dom to use for screenshots, targets, and etc. + clip?: { + x: number; + y: number; + width: number; + height: number; + }; + // base64 image to display in browser. + clipBase64?: string; }; // indexs of automatable issues export type Automatable = { diff --git a/kayle/lib/config.ts b/kayle/lib/config.ts index 1e5592f0..fbc8b646 100644 --- a/kayle/lib/config.ts +++ b/kayle/lib/config.ts @@ -124,6 +124,15 @@ type Page = { title(): Promise; content(): Promise; emulateCPUThrottling(factor: number | null): Promise; + screenshot(s: { + path?: string; + clip?: { + x: number; + y: number; + width: number; + height: number; + }; + }); }; export interface CDPSession { @@ -150,6 +159,12 @@ export type RunnerConfig = { runners?: Runner[]; standard?: keyof typeof Standard | Standard; timeout?: number; + // allow capturing the image visually to base64 + clip?: boolean; + // store clips to a directory must have allowImages set or CDP reset of intercepts + clipDir?: string; + // store a clip to base64 on the issue + clip2Base64?: boolean; // allow images to render. allowImages?: boolean; // the website url: include this even with static html to fetch assets correct. diff --git a/kayle/lib/kayle.ts b/kayle/lib/kayle.ts index 7d7d9807..4e176f77 100644 --- a/kayle/lib/kayle.ts +++ b/kayle/lib/kayle.ts @@ -30,6 +30,7 @@ const audit = async (config: RunnerConfig): Promise => { standard: config.standard, origin: config.origin, language: config.language, + clip: config.clip, } ); }; @@ -93,6 +94,7 @@ export const auditExtension = async (config: RunnerConfig): Promise => { standard: config.standard, origin: config.origin, language: config.language, + clip: config.clip, } ); }; @@ -124,6 +126,41 @@ export const kayle = async ( clearTimeout(watcher.timer); + if (o.clip && results && Array.isArray(results.issues)) { + results.issues = await Promise.all( + results.issues.map(async (item) => { + const { clip, selector } = item; + + try { + const buffer = await o.page.screenshot({ + path: o.clipDir + ? `${o.clipDir}${ + o.clipDir.endsWith("/") ? "" : "/" + }${selector.trim()}.png` + : undefined, + clip: { + x: clip.x, + y: clip.y, + width: clip.width, + height: clip.height, + }, + }); + + // use a dynamic property to inject - todo: set the config initially before this iteration to keep shape aligned. + if (o.clip2Base64) { + item.clipBase64 = buffer.toString("base64"); + } + } catch (_) { + // most likely not in the viewport + // console.error(e); + item.clipBase64 = ""; + } + + return item; + }) + ); + } + if (!preventClose && navigate) { try { await config.page.close(); diff --git a/kayle/lib/option.ts b/kayle/lib/option.ts index ea74a613..0c0f933c 100644 --- a/kayle/lib/option.ts +++ b/kayle/lib/option.ts @@ -23,6 +23,8 @@ export function extractArgs(o, watcher?: Watcher) { standard: o.standard || "WCAG2AA", origin: o.origin || (o.html && "http://localhost") || "", language: o.language || "en", + // store clip tracking element position + clip: o.clip, }; // parse hidden elements into string diff --git a/kayle/lib/runner.ts b/kayle/lib/runner.ts index 109d286f..da319e04 100644 --- a/kayle/lib/runner.ts +++ b/kayle/lib/runner.ts @@ -45,13 +45,17 @@ let hiddenElements = null; // shape the issue - const shapeIssue = (issue) => { + const shapeIssue = (issue, cliped?: boolean) => { let context = ""; let selector = ""; + let clip; if (issue.element) { context = getElementContext(issue.element); selector = getElementSelector(issue.element); + if (cliped) { + clip = issue.element.getBoundingClientRect(); + } } return { @@ -64,6 +68,7 @@ runner: issue.runner || "kayle", runnerExtras: issue.runnerExtras, recurrence: issue.recurrence || 0, + clip, }; }; @@ -182,7 +187,7 @@ continue; } - const issue = shapeIssue(is); + const issue = shapeIssue(is, options.clip); const errorType = issue.type === "error"; diff --git a/kayle/package.json b/kayle/package.json index e1e72b65..be9fbb05 100644 --- a/kayle/package.json +++ b/kayle/package.json @@ -1,6 +1,6 @@ { "name": "kayle", - "version": "0.5.32", + "version": "0.6.0", "description": "Extremely fast and accurate accessibility engine built for any headless tool like playwright or puppeteer.", "main": "./build/index.js", "keywords": [ @@ -39,6 +39,7 @@ "test:playwright": "npm run compile:test && npx playwright test ./tests/basic-playwright.spec.ts", "test:playwright:axe": "npm run compile:test && npx playwright test ./tests/basic-axe-playwright.spec.ts", "test:playwright:htmlcs": "npm run compile:test && npx playwright test ./tests/basic-htmlcs-playwright.spec", + "test:playwright:clips": "npm run compile:test && npx playwright test ./tests/clips-playwright.spec.ts", "test:puppeteer:wasm": "npm run compile:test && node _tests/tests/wasm.js", "test:puppeteer:automa": "npm run compile:test && node _tests/tests/automa.js", "test:puppeteer:extension": "npm run compile:test && yarn build:extension && node _tests/tests/extension.js", diff --git a/kayle/tests/clips-playwright.spec.ts b/kayle/tests/clips-playwright.spec.ts new file mode 100644 index 00000000..b5fec54c --- /dev/null +++ b/kayle/tests/clips-playwright.spec.ts @@ -0,0 +1,65 @@ +import assert from "assert"; +import { writeFileSync } from "fs"; +import { kayle } from "kayle"; +import { drakeMock } from "./mocks/html-mock"; +import { performance } from "perf_hooks"; +import { test } from "@playwright/test"; + +test.setTimeout(120000); + +test("fast_axecore audit drakeMock", async ({ page, browser }, testInfo) => { + if (process.env.LOG_ENABLED) { + page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); + } + const startTime = performance.now(); + const results = await kayle({ + page, + browser, + runners: ["axe"], + includeWarnings: true, + origin: "https://www.drake.com", + html: drakeMock, + waitUntil: "domcontentloaded", + allowImages: true, + clip: true, + clipDir: "./_data/drake.com", + clip2Base64: true, + }); + const endTime = performance.now() - startTime; + + const { issues, pageUrl, documentTitle, meta, automateable } = results; + + console.log(issues); + + console.log([{ meta, automateable }, ["fast_axecore: time took", endTime]]); + + // valid list + assert(Array.isArray(issues)); + assert(typeof pageUrl === "string"); + assert(typeof documentTitle === "string"); + assert(meta.warningCount === 9); + assert(meta.errorCount === 34); + + writeFileSync( + testInfo.outputPath("axe-core.json"), + JSON.stringify(results, null, 2), + "utf8" + ); + + writeFileSync( + testInfo.outputPath("axe-core_stats.json"), + JSON.stringify( + { + mock: "[drakeMock]", + htmlSize: drakeMock.length, + duration: endTime, + errors: meta.errorCount, + warnings: meta.warningCount, + runner: ["fast_axecore"], + }, + null, + 2 + ), + "utf8" + ); +});