From a31d9869b168adee33a122094f7000503c20e05b Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:49:21 +0700 Subject: [PATCH] feat(bundler): BundleWeb + V8-side runtime shim web-runtime.ts installs globalThis.__sanderling__ with extractor / action registries, plus __sanderlingExtractors__ + __sanderlingNextAction__ globals. BundleWeb composes user spec + runtime under esbuild's PlatformBrowser into one IIFE. --- internal/bundler/web_bundle.go | 113 +++++++ internal/bundler/web_bundle_test.go | 91 ++++++ pkg/spec/src/web-runtime.ts | 464 ++++++++++++++++++++++++++++ 3 files changed, 668 insertions(+) create mode 100644 internal/bundler/web_bundle.go create mode 100644 internal/bundler/web_bundle_test.go create mode 100644 pkg/spec/src/web-runtime.ts diff --git a/internal/bundler/web_bundle.go b/internal/bundler/web_bundle.go new file mode 100644 index 0000000..af92d3c --- /dev/null +++ b/internal/bundler/web_bundle.go @@ -0,0 +1,113 @@ +package bundler + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// WebOptions configures BundleWeb. WebRuntimeFile points at +// pkg/spec/src/web-runtime.ts (resolved via the same upward search the regular +// Bundle uses for @sanderling/spec). When empty, BundleWeb expects callers to +// have set Aliases such that "@sanderling/spec/web-runtime" resolves. +type WebOptions struct { + EntryFile string + WebRuntimeFile string + Defines map[string]string + Aliases map[string]string +} + +// BundleWeb compiles the user spec together with the V8-side runtime into a +// single IIFE that, on evaluation, installs `globalThis.__sanderling__` and +// the `__sanderlingExtractors__` / `__sanderlingNextAction__` globals. The +// host injects the result via Page.AddScriptToEvaluateOnNewDocument. +func BundleWeb(options WebOptions) (Result, error) { + if options.EntryFile == "" { + return Result{}, errors.New("EntryFile is required") + } + if options.WebRuntimeFile == "" { + return Result{}, errors.New("WebRuntimeFile is required") + } + if _, err := filepath.Abs(options.EntryFile); err != nil { + return Result{}, fmt.Errorf("entry path: %w", err) + } + runtimeAbs, err := filepath.Abs(options.WebRuntimeFile) + if err != nil { + return Result{}, fmt.Errorf("runtime path: %w", err) + } + specAbs, err := filepath.Abs(options.EntryFile) + if err != nil { + return Result{}, fmt.Errorf("entry path: %w", err) + } + + defines := map[string]string{} + for key, value := range options.Defines { + defines["process.env."+key] = quoteJSString(value) + } + + stdinContents := fmt.Sprintf(`import %q; +import %q; +`, runtimeAbs, specAbs) + + output := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{ + Contents: stdinContents, + ResolveDir: filepath.Dir(specAbs), + Loader: esbuild.LoaderTS, + }, + Bundle: true, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2020, + Platform: esbuild.PlatformBrowser, + Define: defines, + Alias: options.Aliases, + Write: false, + LogLevel: esbuild.LogLevelSilent, + }) + + if len(output.Errors) > 0 { + var messages []string + for _, message := range output.Errors { + messages = append(messages, message.Text) + } + return Result{}, fmt.Errorf("web bundle failed: %s", strings.Join(messages, "; ")) + } + if len(output.OutputFiles) == 0 { + return Result{}, errors.New("web bundle produced no output files") + } + + javascript := output.OutputFiles[0].Contents + sum := sha256.Sum256(javascript) + return Result{ + JavaScript: javascript, + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +func quoteJSString(value string) string { + var builder strings.Builder + builder.WriteByte('"') + for index := 0; index < len(value); index++ { + c := value[index] + switch c { + case '"', '\\': + builder.WriteByte('\\') + builder.WriteByte(c) + case '\n': + builder.WriteString("\\n") + case '\r': + builder.WriteString("\\r") + case '\t': + builder.WriteString("\\t") + default: + builder.WriteByte(c) + } + } + builder.WriteByte('"') + return builder.String() +} diff --git a/internal/bundler/web_bundle_test.go b/internal/bundler/web_bundle_test.go new file mode 100644 index 0000000..bd939cb --- /dev/null +++ b/internal/bundler/web_bundle_test.go @@ -0,0 +1,91 @@ +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const fakeRuntime = ` +(globalThis as Record).__sanderling__ = { + extract: (g: () => unknown) => ({ current: g(), previous: undefined }), + always: () => ({ __sanderlingFormula: true }), + now: () => ({ __sanderlingFormula: true }), + next: () => ({ __sanderlingFormula: true }), + eventually: () => ({ __sanderlingFormula: true }), + actions: (g: () => unknown) => ({ __sanderlingActionGenerator: true, generate: g }), + weighted: () => ({ __sanderlingActionGenerator: true }), + from: () => ({ generate: () => null }), + tap: () => null, + inputText: () => null, + swipe: () => null, + pressKey: () => null, + wait: () => null, + taps: {}, + swipes: {}, + waitOnce: {}, + pressKeys: {}, +}; +(globalThis as Record).__sanderlingExtractors__ = () => ({}); +(globalThis as Record).__sanderlingNextAction__ = () => null; +export {}; +` + +const fakeSpec = ` +const handle = (globalThis as { __sanderling__: { extract: (g: () => unknown) => unknown } }).__sanderling__.extract(() => 42); +(globalThis as { actions?: unknown }).actions = handle; +export {}; +` + +func TestBundleWeb_RegistersExpectedGlobals(t *testing.T) { + directory := t.TempDir() + runtimePath := filepath.Join(directory, "web-runtime.ts") + specPath := filepath.Join(directory, "spec.ts") + if err := os.WriteFile(runtimePath, []byte(fakeRuntime), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(fakeSpec), 0o644); err != nil { + t.Fatal(err) + } + + result, err := BundleWeb(WebOptions{ + EntryFile: specPath, + WebRuntimeFile: runtimePath, + }) + if err != nil { + t.Fatalf("BundleWeb: %v", err) + } + source := string(result.JavaScript) + for _, expected := range []string{ + "__sanderlingExtractors__", + "__sanderlingNextAction__", + "__sanderling__", + } { + if !strings.Contains(source, expected) { + t.Errorf("bundle missing %q\nsource head:\n%s", expected, head(source, 500)) + } + } + if !strings.HasPrefix(strings.TrimSpace(source), "(()") { + t.Errorf("expected IIFE format, head:\n%s", head(source, 200)) + } + if result.SHA256 == "" { + t.Errorf("expected non-empty sha256") + } +} + +func TestBundleWeb_RejectsMissingEntry(t *testing.T) { + if _, err := BundleWeb(WebOptions{}); err == nil { + t.Error("expected error for empty options") + } + if _, err := BundleWeb(WebOptions{EntryFile: "x.ts"}); err == nil { + t.Error("expected error for missing runtime") + } +} + +func head(text string, max int) string { + if len(text) <= max { + return text + } + return text[:max] + "..." +} diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts new file mode 100644 index 0000000..ff6da27 --- /dev/null +++ b/pkg/spec/src/web-runtime.ts @@ -0,0 +1,464 @@ +/// + +// V8-side runtime for `sanderling test --platform web`. +// +// The user spec is bundled with this file as the first import so that +// globalThis.__sanderling__ is installed before the spec evaluates. The host +// invokes window.__sanderlingExtractors__() and window.__sanderlingNextAction__() +// over CDP each tick. LTL predicates are intentionally stubbed: properties +// run host-side in goja, which loads its own bundle of the same spec. +// +// Element references never cross V8/host. Action targets that reference an +// AccessibilityElement collapse to `{x, y}` from getBoundingClientRect() +// before serialization. + +interface Handle { + current: unknown; + previous: unknown; +} + +interface ExtractorEntry { + getter: (state: unknown) => unknown; + handle: Handle; +} + +interface ActionGeneratorHandle { + __sanderlingActionGenerator: true; + __sanderlingKind: string; + generate?: () => unknown; + entries?: readonly [number, ActionGeneratorHandle][]; +} + +const extractors: ExtractorEntry[] = []; +let actionsRoot: ActionGeneratorHandle | null = null; + +function noopFormula(): unknown { + const formula: Record = { __sanderlingFormula: true }; + formula.implies = () => formula; + formula.or = () => formula; + formula.and = () => formula; + formula.not = () => formula; + formula.within = () => formula; + return formula; +} + +const KNOWN_KEY_TO_CSS: Record string> = { + id: (v) => `[id="${cssEscape(v)}"]`, + "resource-id": (v) => `[id="${cssEscape(v)}"]`, + testTag: (v) => `[data-testid="${cssEscape(v)}"]`, + testID: (v) => `[data-testid="${cssEscape(v)}"]`, + "data-testid": (v) => `[data-testid="${cssEscape(v)}"]`, + className: (v) => `.${cssEscape(v)}`, + class: (v) => `.${cssEscape(v)}`, + tag: (v) => cssEscape(v), + text: (v) => `:scope :is(*[contains-text="${cssEscape(v)}"])`, // unused; text uses XPath + "aria-label": (v) => `[aria-label="${cssEscape(v)}"]`, + ariaLabel: (v) => `[aria-label="${cssEscape(v)}"]`, + accessibilityLabel: (v) => `[aria-label="${cssEscape(v)}"]`, + contentDescription: (v) => `[aria-label="${cssEscape(v)}"]`, + "content-desc": (v) => `[aria-label="${cssEscape(v)}"]`, + label: (v) => `[aria-label="${cssEscape(v)}"]`, + placeholder: (v) => `[placeholder="${cssEscape(v)}"]`, + placeholderValue: (v) => `[placeholder="${cssEscape(v)}"]`, + hintText: (v) => `[placeholder="${cssEscape(v)}"]`, +}; + +function cssEscape(value: string): string { + return value.replace(/(["\\])/g, "\\$1"); +} + +function selectorFromObject(selector: Record): { + css?: string; + xpath?: string; +} { + const parts: string[] = []; + let textValue: string | undefined; + let descPrefix: string | undefined; + for (const key of Object.keys(selector)) { + const raw = selector[key]; + if (raw === undefined) continue; + const value = typeof raw === "boolean" ? String(raw) : raw; + if (key === "text") { + textValue = value; + continue; + } + if (key === "descPrefix") { + descPrefix = value; + continue; + } + const builder = KNOWN_KEY_TO_CSS[key]; + if (builder) { + parts.push(builder(value)); + } else { + parts.push(`[${key}="${cssEscape(value)}"]`); + } + } + if (descPrefix !== undefined) { + parts.push(`[aria-label^="${cssEscape(descPrefix)}"]`); + } + if (textValue !== undefined && parts.length === 0) { + return { + xpath: `//*[normalize-space(text())="${textValue.replace(/"/g, '\\"')}"]`, + }; + } + return { css: parts.join("") }; +} + +function selectorFromString(selector: string): { css?: string; xpath?: string } { + const colon = selector.indexOf(":"); + if (colon <= 0) { + return { css: selector }; + } + const kind = selector.slice(0, colon); + const value = selector.slice(colon + 1); + if (kind === "text") { + return { xpath: `//*[normalize-space(text())="${value.replace(/"/g, '\\"')}"]` }; + } + if (kind === "descPrefix") { + return { css: `[aria-label^="${cssEscape(value)}"]` }; + } + return selectorFromObject({ [kind]: value }); +} + +function queryElement( + root: ParentNode, + selector: unknown, +): Element | null { + if (typeof selector === "string") { + const { css, xpath } = selectorFromString(selector); + if (css) return root.querySelector(css); + if (xpath) { + const result = document.evaluate( + xpath, + root as Node, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ); + return result.singleNodeValue as Element | null; + } + return null; + } + if (Array.isArray(selector)) { + let node: ParentNode | null = root; + for (const segment of selector) { + if (!node) return null; + const next = queryElement(node, segment); + if (!next) return null; + node = next; + } + return node as Element; + } + if (selector && typeof selector === "object") { + const { css, xpath } = selectorFromObject(selector as Record); + if (css) return root.querySelector(css); + if (xpath) { + const result = document.evaluate( + xpath, + root as Node, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ); + return result.singleNodeValue as Element | null; + } + } + return null; +} + +function queryAllElements(root: ParentNode, selector: unknown): Element[] { + if (typeof selector === "string") { + const { css, xpath } = selectorFromString(selector); + if (css) return Array.from(root.querySelectorAll(css)); + if (xpath) return evaluateXPathAll(xpath, root as Node); + return []; + } + if (selector && typeof selector === "object" && !Array.isArray(selector)) { + const { css, xpath } = selectorFromObject(selector as Record); + if (css) return Array.from(root.querySelectorAll(css)); + if (xpath) return evaluateXPathAll(xpath, root as Node); + } + return []; +} + +function evaluateXPathAll(xpath: string, root: Node): Element[] { + const result = document.evaluate( + xpath, + root, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + const out: Element[] = []; + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i); + if (node) out.push(node as Element); + } + return out; +} + +function elementHandle(element: Element): Record { + const rect = element.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + const ariaLabel = element.getAttribute("aria-label") ?? ""; + const text = (element.textContent ?? "").trim().slice(0, 200); + const datasetCopy: Record = {}; + const dataset = (element as HTMLElement).dataset ?? {}; + for (const key of Object.keys(dataset)) { + const value = (dataset as Record)[key]; + if (value !== undefined) datasetCopy[key] = value; + } + return { + id: element.id, + text, + desc: ariaLabel, + class: (element as HTMLElement).className ?? "", + clickable: true, + enabled: !(element as HTMLButtonElement).disabled, + focused: document.activeElement === element, + x, + y, + bounds: { + left: Math.round(rect.left), + top: Math.round(rect.top), + right: Math.round(rect.right), + bottom: Math.round(rect.bottom), + }, + attrs: { + tag: element.tagName.toLowerCase(), + "aria-label": ariaLabel, + ...datasetCopy, + }, + dataset: datasetCopy, + find(selector: unknown): unknown { + const child = queryElement(element, selector); + return child ? elementHandle(child) : undefined; + }, + findAll(selector: unknown): unknown[] { + return queryAllElements(element, selector).map(elementHandle); + }, + }; +} + +function buildAx(): unknown { + return { + find(selector: unknown): unknown { + const element = queryElement(document, selector); + return element ? elementHandle(element) : undefined; + }, + findAll(selector: unknown): unknown[] { + return queryAllElements(document, selector).map(elementHandle); + }, + }; +} + +function buildState(): unknown { + return { + snapshots: {}, + ax: buildAx(), + document, + window, + lastAction: null, + time: 0, + logs: [], + exceptions: [], + }; +} + +const runtime = { + extract(getter: (state: unknown) => T): Handle { + const handle: Handle = { current: undefined, previous: undefined }; + extractors.push({ getter: getter as (s: unknown) => unknown, handle }); + return handle; + }, + always: noopFormula, + now: noopFormula, + next: noopFormula, + eventually: noopFormula, + actions(generator: () => unknown): ActionGeneratorHandle { + const handle: ActionGeneratorHandle = { + __sanderlingActionGenerator: true, + __sanderlingKind: "actions", + generate: generator, + }; + if (!actionsRoot) actionsRoot = handle; + return handle; + }, + weighted(...entries: [number, ActionGeneratorHandle][]): ActionGeneratorHandle { + const handle: ActionGeneratorHandle = { + __sanderlingActionGenerator: true, + __sanderlingKind: "weighted", + entries, + }; + actionsRoot = handle; + return handle; + }, + from(items: readonly T[]): { generate: () => T | undefined } { + return { + generate(): T | undefined { + if (items.length === 0) return undefined; + return items[Math.floor(Math.random() * items.length)]; + }, + }; + }, + tap(p: { on: unknown }): unknown { + return { kind: "Tap", on: p.on }; + }, + inputText(p: { into: unknown; text: string }): unknown { + return { kind: "InputText", into: p.into, text: p.text }; + }, + swipe(p: { from: unknown; to: unknown; durationMillis?: number }): unknown { + return { kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }; + }, + pressKey(p: { key: string }): unknown { + return { kind: "PressKey", key: p.key }; + }, + wait(p: { durationMillis: number }): unknown { + return { kind: "Wait", durationMillis: p.durationMillis }; + }, + taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle, + swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle, + waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle, + pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle, +}; + +(globalThis as Record).__sanderling__ = runtime; + +function evaluateExtractors(): Record { + const state = buildState(); + const result: Record = {}; + for (let i = 0; i < extractors.length; i++) { + const entry = extractors[i]; + if (!entry) continue; + entry.handle.previous = entry.handle.current; + let value: unknown; + try { + value = entry.getter(state); + } catch { + value = undefined; + } + entry.handle.current = value; + result[i] = sanitize(value); + } + return result; +} + +function sanitize(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (typeof value === "function") return undefined; + if (Array.isArray(value)) return value.map(sanitize); + if (typeof value === "object") { + const out: Record = {}; + for (const key of Object.keys(value as Record)) { + const sub = (value as Record)[key]; + if (typeof sub === "function") continue; + out[key] = sanitize(sub); + } + return out; + } + return value; +} + +function pickWeighted(handle: ActionGeneratorHandle): ActionGeneratorHandle | null { + const entries = handle.entries ?? []; + if (entries.length === 0) return null; + let total = 0; + for (const [weight] of entries) total += Math.max(0, weight); + if (total <= 0) return null; + let pick = Math.random() * total; + for (const [weight, generator] of entries) { + pick -= Math.max(0, weight); + if (pick <= 0) return generator; + } + return entries[entries.length - 1]?.[1] ?? null; +} + +function resolveGenerator(handle: ActionGeneratorHandle): unknown { + switch (handle.__sanderlingKind) { + case "actions": { + const generated = handle.generate?.(); + return pickFromArray(generated); + } + case "weighted": { + const inner = pickWeighted(handle); + if (!inner) return null; + return resolveGenerator(inner); + } + case "taps": + return null; // host-side fallback handles random taps using hierarchy + case "swipes": + return null; + case "waitOnce": + return { kind: "Wait", durationMillis: 500 }; + case "pressKey": + return { kind: "PressKey", key: "back" }; + default: + return null; + } +} + +function pickFromArray(value: unknown): unknown { + if (!value) return null; + if (!Array.isArray(value)) return value; + if (value.length === 0) return null; + return value[Math.floor(Math.random() * value.length)]; +} + +function serializeAction(action: unknown): unknown { + if (!action || typeof action !== "object") return null; + const obj = action as Record; + switch (obj.kind) { + case "Tap": { + const point = pointOf(obj.on); + return { kind: "Tap", x: point?.x ?? 0, y: point?.y ?? 0 }; + } + case "InputText": { + const point = pointOf(obj.into); + return { + kind: "InputText", + x: point?.x ?? 0, + y: point?.y ?? 0, + text: obj.text ?? "", + }; + } + case "Swipe": { + const from = pointOf(obj.from) ?? { x: 0, y: 0 }; + const to = pointOf(obj.to) ?? { x: 0, y: 0 }; + return { + kind: "Swipe", + from_x: from.x, + from_y: from.y, + to_x: to.x, + to_y: to.y, + duration_millis: obj.durationMillis ?? 250, + }; + } + case "PressKey": + return { kind: "PressKey", key: obj.key }; + case "Wait": + return { kind: "Wait", duration_millis: obj.durationMillis ?? 0 }; + default: + return null; + } +} + +function pointOf(value: unknown): { x: number; y: number } | undefined { + if (!value || typeof value !== "object") return undefined; + const obj = value as Record; + if (typeof obj.x === "number" && typeof obj.y === "number") { + return { x: obj.x, y: obj.y }; + } + return undefined; +} + +(globalThis as Record).__sanderlingExtractors__ = function (): Record { + return evaluateExtractors(); +}; + +(globalThis as Record).__sanderlingNextAction__ = function (): unknown { + if (!actionsRoot) return null; + return serializeAction(resolveGenerator(actionsRoot)); +}; + +export {};