mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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.
This commit is contained in:
1 parent
ee3737c831
commit
a31d9869b1
3 files changed
+668
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fakeRuntime = `
|
||||
(globalThis as Record<string, unknown>).__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<string, unknown>).__sanderlingExtractors__ = () => ({});
|
||||
(globalThis as Record<string, unknown>).__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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
// 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<string, unknown> = { __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, (value: string) => 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<string, string | boolean | undefined>): {
|
||||
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<string, string | boolean | undefined>);
|
||||
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<string, string | boolean | undefined>);
|
||||
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<string, unknown> {
|
||||
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<string, string> = {};
|
||||
const dataset = (element as HTMLElement).dataset ?? {};
|
||||
for (const key of Object.keys(dataset)) {
|
||||
const value = (dataset as Record<string, string | undefined>)[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<T>(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<T>(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<string, unknown>).__sanderling__ = runtime;
|
||||
|
||||
function evaluateExtractors(): Record<number, unknown> {
|
||||
const state = buildState();
|
||||
const result: Record<number, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
const sub = (value as Record<string, unknown>)[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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (typeof obj.x === "number" && typeof obj.y === "number") {
|
||||
return { x: obj.x, y: obj.y };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
(globalThis as Record<string, unknown>).__sanderlingExtractors__ = function (): Record<number, unknown> {
|
||||
return evaluateExtractors();
|
||||
};
|
||||
|
||||
(globalThis as Record<string, unknown>).__sanderlingNextAction__ = function (): unknown {
|
||||
if (!actionsRoot) return null;
|
||||
return serializeAction(resolveGenerator(actionsRoot));
|
||||
};
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user