210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
|
|
import type { NavigateFunction } from 'react-router-dom'
|
||
|
|
import { ISLANDER_CONFIG } from './config'
|
||
|
|
import {
|
||
|
|
capturePageSnapshot,
|
||
|
|
findElementByLabel,
|
||
|
|
formatPageSnapshot,
|
||
|
|
resolveSnapshotElement,
|
||
|
|
} from './pageSnapshot'
|
||
|
|
import type {
|
||
|
|
IslanderAction,
|
||
|
|
IslanderActionHandler,
|
||
|
|
IslanderActionResult,
|
||
|
|
IslanderExecutorContext,
|
||
|
|
PageSnapshot,
|
||
|
|
} from './types'
|
||
|
|
|
||
|
|
const customHandlers = new Map<string, IslanderActionHandler>()
|
||
|
|
|
||
|
|
/** Register custom action handlers for future page-specific extensions. */
|
||
|
|
export function registerIslanderActionHandler(type: string, handler: IslanderActionHandler) {
|
||
|
|
customHandlers.set(type, handler)
|
||
|
|
}
|
||
|
|
|
||
|
|
function waitMs(ms: number): Promise<void> {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
window.setTimeout(() => resolve(), ms)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||
|
|
const proto =
|
||
|
|
el instanceof HTMLTextAreaElement
|
||
|
|
? window.HTMLTextAreaElement.prototype
|
||
|
|
: window.HTMLInputElement.prototype
|
||
|
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
||
|
|
const previous = el.value
|
||
|
|
setter?.call(el, value)
|
||
|
|
|
||
|
|
const tracker = (el as HTMLInputElement & { _valueTracker?: { setValue: (v: string) => void } })
|
||
|
|
._valueTracker
|
||
|
|
if (tracker) {
|
||
|
|
tracker.setValue(previous)
|
||
|
|
}
|
||
|
|
|
||
|
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||
|
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveFillTarget(
|
||
|
|
action: Extract<IslanderAction, { type: 'fill' }>,
|
||
|
|
ctx: IslanderExecutorContext,
|
||
|
|
): HTMLInputElement | HTMLTextAreaElement | null {
|
||
|
|
if (action.ref) {
|
||
|
|
const byRef = ctx.resolveRef(action.ref)
|
||
|
|
if (byRef instanceof HTMLInputElement || byRef instanceof HTMLTextAreaElement) return byRef
|
||
|
|
}
|
||
|
|
if (action.label) {
|
||
|
|
const byLabel = findElementByLabel(action.label)
|
||
|
|
if (byLabel instanceof HTMLInputElement || byLabel instanceof HTMLTextAreaElement) return byLabel
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
function flashHighlight(el: HTMLElement) {
|
||
|
|
el.classList.add(ISLANDER_CONFIG.highlightClass)
|
||
|
|
window.setTimeout(() => el.classList.remove(ISLANDER_CONFIG.highlightClass), ISLANDER_CONFIG.highlightDurationMs)
|
||
|
|
}
|
||
|
|
|
||
|
|
function isBlockedClick(el: HTMLElement) {
|
||
|
|
const label = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
|
||
|
|
return ISLANDER_CONFIG.blockedClickPatterns.some((pattern) => pattern.test(label))
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildExecutorContext(
|
||
|
|
navigate: NavigateFunction,
|
||
|
|
snapshot: PageSnapshot,
|
||
|
|
): IslanderExecutorContext {
|
||
|
|
return {
|
||
|
|
navigate: (path) => navigate(path),
|
||
|
|
snapshot,
|
||
|
|
waitMs,
|
||
|
|
highlight: flashHighlight,
|
||
|
|
resolveRef: (ref) => resolveSnapshotElement(snapshot.refMap, ref),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runBuiltinAction(
|
||
|
|
action: IslanderAction,
|
||
|
|
ctx: IslanderExecutorContext,
|
||
|
|
): Promise<IslanderActionResult> {
|
||
|
|
switch (action.type) {
|
||
|
|
case 'wait': {
|
||
|
|
const ms = Math.min(Math.max(action.ms ?? 300, 0), 3000)
|
||
|
|
await ctx.waitMs(ms)
|
||
|
|
return { action, ok: true, detail: `waited ${ms}ms` }
|
||
|
|
}
|
||
|
|
case 'navigate': {
|
||
|
|
const path = action.path.startsWith('/') ? action.path : `/${action.path}`
|
||
|
|
if (!path.startsWith('/') || path.startsWith('//')) {
|
||
|
|
return { action, ok: false, detail: '只允許站內路徑' }
|
||
|
|
}
|
||
|
|
ctx.navigate(path)
|
||
|
|
await ctx.waitMs(ISLANDER_CONFIG.navigateWaitMs)
|
||
|
|
return { action, ok: true, detail: path }
|
||
|
|
}
|
||
|
|
case 'highlight': {
|
||
|
|
const el = ctx.resolveRef(action.ref)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到元素' }
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
ctx.highlight(el)
|
||
|
|
return { action, ok: true, detail: el.textContent?.trim().slice(0, 40) ?? action.ref }
|
||
|
|
}
|
||
|
|
case 'focus': {
|
||
|
|
const el = ctx.resolveRef(action.ref)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到元素' }
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
if ('focus' in el && typeof el.focus === 'function') el.focus()
|
||
|
|
ctx.highlight(el)
|
||
|
|
return { action, ok: true, detail: action.ref }
|
||
|
|
}
|
||
|
|
case 'scroll': {
|
||
|
|
if (action.ref) {
|
||
|
|
const el = ctx.resolveRef(action.ref)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到元素' }
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
return { action, ok: true, detail: action.ref }
|
||
|
|
}
|
||
|
|
window.scrollTo({ top: action.top ?? 0, behavior: 'smooth' })
|
||
|
|
return { action, ok: true, detail: `top=${action.top ?? 0}` }
|
||
|
|
}
|
||
|
|
case 'click': {
|
||
|
|
const el = ctx.resolveRef(action.ref)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到元素' }
|
||
|
|
if (isBlockedClick(el)) return { action, ok: false, detail: '此操作需使用者自行確認' }
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
await ctx.waitMs(120)
|
||
|
|
ctx.highlight(el)
|
||
|
|
el.click()
|
||
|
|
await ctx.waitMs(280)
|
||
|
|
return { action, ok: true, detail: el.textContent?.trim().slice(0, 40) ?? action.ref }
|
||
|
|
}
|
||
|
|
case 'fill': {
|
||
|
|
const el = resolveFillTarget(action, ctx)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到可填寫的欄位' }
|
||
|
|
if (el instanceof HTMLInputElement && el.type === 'password') {
|
||
|
|
return { action, ok: false, detail: '密碼欄位請使用者自行輸入' }
|
||
|
|
}
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
if ('focus' in el && typeof el.focus === 'function') el.focus()
|
||
|
|
setNativeValue(el, action.value)
|
||
|
|
ctx.highlight(el)
|
||
|
|
return { action, ok: true, detail: action.value.slice(0, 40) }
|
||
|
|
}
|
||
|
|
case 'select': {
|
||
|
|
const el = ctx.resolveRef(action.ref)
|
||
|
|
if (!el) return { action, ok: false, detail: '找不到元素' }
|
||
|
|
if (!(el instanceof HTMLSelectElement)) {
|
||
|
|
return { action, ok: false, detail: '此元素不是下拉選單' }
|
||
|
|
}
|
||
|
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
el.value = action.value
|
||
|
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||
|
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||
|
|
ctx.highlight(el)
|
||
|
|
return { action, ok: true, detail: action.value }
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return { action, ok: false, detail: '未知操作' }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runAction(
|
||
|
|
action: IslanderAction,
|
||
|
|
navigate: NavigateFunction,
|
||
|
|
snapshot: PageSnapshot,
|
||
|
|
): Promise<IslanderActionResult> {
|
||
|
|
const ctx = buildExecutorContext(navigate, snapshot)
|
||
|
|
const custom = customHandlers.get(action.type)
|
||
|
|
if (custom) {
|
||
|
|
const result = await custom(action, ctx)
|
||
|
|
if (result) return result
|
||
|
|
}
|
||
|
|
return runBuiltinAction(action, ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
type ExecuteOptions = {
|
||
|
|
actions: IslanderAction[]
|
||
|
|
navigate: NavigateFunction
|
||
|
|
snapshot?: PageSnapshot
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function executeIslanderActions(opts: ExecuteOptions): Promise<{
|
||
|
|
results: IslanderActionResult[]
|
||
|
|
snapshotText: string
|
||
|
|
}> {
|
||
|
|
const snapshot = opts.snapshot ?? capturePageSnapshot()
|
||
|
|
const results: IslanderActionResult[] = []
|
||
|
|
|
||
|
|
for (const action of opts.actions) {
|
||
|
|
const result = await runAction(action, opts.navigate, snapshot)
|
||
|
|
results.push(result)
|
||
|
|
if (!result.ok && action.type !== 'wait') break
|
||
|
|
}
|
||
|
|
|
||
|
|
await waitMs(200)
|
||
|
|
return {
|
||
|
|
results,
|
||
|
|
snapshotText: formatPageSnapshot(capturePageSnapshot()),
|
||
|
|
}
|
||
|
|
}
|