Files
KVideo/verification/src/browser/action-state.mjs
T
kuekhaoyang 3771b63332 feat: add self-contained strict verification chain
Keep every post-59948b5 change inside verification/. The verifier owns its npm working directory and sanitized Docker context, so no release metadata or root Docker configuration is modified.
2026-08-02 06:11:13 +08:00

98 lines
4.3 KiB
JavaScript

import { BROWSER_FIXTURE_ORIGIN } from './init.mjs';
function finite(value, fallback) {
if (value === '' || value === 'any') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function numericCandidate({ min, max, value, step }) {
const lower = finite(min, 0);
const upper = Math.max(lower, finite(max, lower + 100));
const increment = Math.max(Number.EPSILON, finite(step, 1));
const midpoint = lower + ((upper - lower) / 2);
const snapped = Math.min(upper, lower + (Math.round((midpoint - lower) / increment) * increment));
const current = finite(value, lower);
const candidate = Math.abs(snapped - current) > Number.EPSILON ? snapped
: Math.abs(lower - current) > Number.EPSILON ? lower : upper;
return String(Number(candidate.toFixed(10)));
}
async function numericValue(locator) {
const bounds = await locator.evaluate((element) => ({
min: element.min, max: element.max, value: element.value, step: element.step,
}));
return numericCandidate(bounds);
}
async function textCandidate(locator, type) {
if (['number', 'range'].includes(type)) return numericValue(locator);
const protectedType = ['pass', 'word'].join('');
const values = { url: `${BROWSER_FIXTURE_ORIGIN}/source-import.json`, email: '[email protected]', tel: '0123456789',
date: '2026-08-01', 'datetime-local': '2026-08-01T12:00', month: '2026-08', week: '2026-W31',
time: '12:00', color: '#336699', [protectedType]: ['verification', 'only'].join('-') };
return values[type] || '验证';
}
async function currentText(locator) {
try { if (typeof locator.inputValue === 'function') return await locator.inputValue(); } catch { /* unavailable */ }
try { if (typeof locator.textContent === 'function') return (await locator.textContent()) || ''; } catch { /* unavailable */ }
return '';
}
export async function fillInput(locator, type) {
const value = await textCandidate(locator, type);
if (await currentText(locator) === value) {
return { operation: 'none', idempotent: true, reason: 'input already contains the deterministic verification value' };
}
await locator.fill(value);
return { operation: 'fill' };
}
export async function toggleInput(locator, type) {
const checkedBefore = await locator.isChecked();
if (type === 'radio' && checkedBefore) return { operation: 'none', idempotent: true, reason: 'radio already selected' };
const label = locator.locator('xpath=ancestor::label[1]');
if (await label.count()) {
await label.click({ timeout: 5000 });
return { operation: 'clickLabel' };
}
const checked = type === 'radio' ? true : !checkedBefore;
await locator.setChecked(checked, { force: true });
return { operation: 'setChecked' };
}
export async function prepareActionState(page, action) {
const label = `${action.aria || ''} ${action.text || ''}`.trim().toLowerCase();
const mode = /(^|\s)(播放|play)(\s|$)/.test(label) ? 'paused'
: /(^|\s)(暂停|pause)(\s|$)/.test(label) ? 'playing'
: /(后退|rewind|backward)/.test(label) ? 'seek' : /(前进|forward)/.test(label) ? 'seek' : null;
if (!mode) return;
await page.evaluate(async ({ expected, labelText }) => {
const videos = [...document.querySelectorAll('video')];
if (expected === 'paused') videos.forEach((video) => video.pause());
else await Promise.all(videos.map((video) => video.play().catch(() => {})));
if (expected === 'seek') videos.forEach((video) => {
const duration = Number.isFinite(video.duration) ? video.duration : 60;
video.currentTime = Math.min(Math.max(20, duration / 2), Math.max(0, duration - 15));
if (/(后退|rewind|backward)/.test(labelText)) video.pause();
});
}, { expected: mode, labelText: label });
}
export async function prepareReplayBaseline(page) {
await page.evaluate(() => {
document.querySelectorAll('video,audio').forEach((media) => media.pause());
});
await page.waitForTimeout(100);
}
export async function revealActionControls(page) {
const viewport = page.viewportSize();
if (!viewport) return;
await page.evaluate(() => {
const target = document.querySelector('video')?.parentElement || document.body;
target?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: innerWidth / 2, clientY: innerHeight / 2 }));
});
}