mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge origin/master into llm-recording-and-analysis
Brings in #73, which landed web fact-parity work overlapping this branch: selector-tagged ax handles, aria-disabled in `enabled`, shadow-DOM traversal and a `scrollable`/`editable` dump, plus a third folio property and two new testTags on HomeScreen. Six files conflicted. The option-carrying ones took the union of both sides' fields, so `--label-source` and `--exit-on-violation` both reach the pipeline. In web-runtime.ts both sides changed how an element is described: master gave `elementHandle` a selector to tag handles with, this branch gave it raw attribute names and a field hint. Both survive, and `enabled` now answers through master's isEnabled while `editable` stays. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
commit
d987526e47
73 files changed
+6639
-457
No files matched your search
@@ -256,12 +256,7 @@ func (d *Driver) InputText(callerCtx context.Context, text string) error {
|
||||
defer cancel()
|
||||
return chromedp.Run(runCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// Select any existing content so InsertText replaces rather than appends.
|
||||
if err := chromedp.Evaluate(`
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (el && typeof el.select === 'function') el.select();
|
||||
})()`, nil).Do(ctx); err != nil {
|
||||
if err := selectFocusedText(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return input.InsertText(text).Do(ctx)
|
||||
@@ -269,6 +264,28 @@ func (d *Driver) InputText(callerCtx context.Context, text string) error {
|
||||
)
|
||||
}
|
||||
|
||||
// selectAllScript selects everything in the focused field so the InsertText
|
||||
// that follows replaces rather than appends.
|
||||
//
|
||||
// document.activeElement stops at a shadow boundary: it names the HOST, not the
|
||||
// focused node inside. Compose for Web focuses a hidden <input> inside the
|
||||
// shadow root it mounts, so the host answer has no select() and the selection
|
||||
// never happened - every InputText appended to the last one, and a fuzzer that
|
||||
// types into the same field twice built up garbage it could never clear.
|
||||
// Descending activeElement through each shadow root finds the real field.
|
||||
const selectAllScript = `
|
||||
(function() {
|
||||
let el = document.activeElement;
|
||||
while (el && el.shadowRoot && el.shadowRoot.activeElement) {
|
||||
el = el.shadowRoot.activeElement;
|
||||
}
|
||||
if (el && typeof el.select === 'function') el.select();
|
||||
})()`
|
||||
|
||||
func selectFocusedText(ctx context.Context) error {
|
||||
return chromedp.Evaluate(selectAllScript, nil).Do(ctx)
|
||||
}
|
||||
|
||||
// ReplacesTextOnInput reports that InputText replaces existing content via
|
||||
// select-all, so the runner skips its pre-erase.
|
||||
func (d *Driver) ReplacesTextOnInput() bool {
|
||||
@@ -282,11 +299,7 @@ func (d *Driver) EraseText(callerCtx context.Context, _ int) error {
|
||||
defer cancel()
|
||||
return chromedp.Run(runCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if err := chromedp.Evaluate(`
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (el && typeof el.select === 'function') el.select();
|
||||
})()`, nil).Do(ctx); err != nil {
|
||||
if err := selectFocusedText(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return input.InsertText("").Do(ctx)
|
||||
@@ -368,7 +381,11 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
defer cancel()
|
||||
script := `
|
||||
(function() {
|
||||
const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
|
||||
// Hash first (a HashRouter names the screen there), then the pathname, which
|
||||
// is where a path-routed SPA keeps it. Reporting '/' for every step of a
|
||||
// BrowserRouter app made every screen look like the same screen.
|
||||
const route = window.location.hash.replace(/^#/, '').split('?')[0] ||
|
||||
window.location.pathname || '/';
|
||||
// clickable and editable are resolved through the SAME selector sets
|
||||
// pkg/spec/src/web-runtime.ts uses, so the goja host (which reads this dump)
|
||||
// and the V8 host (which reads the DOM directly) cannot mean different things
|
||||
@@ -376,6 +393,14 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
// root a full-viewport tap target here and nowhere else.
|
||||
const NON_TEXT_INPUT_TYPES =
|
||||
['button','submit','checkbox','radio','range','color','file','image','reset'];
|
||||
// The disabled property belongs to real form controls only, so it reads
|
||||
// undefined on the role-based controls the tappable set now covers, and every
|
||||
// one of them looked enabled however plainly it was marked otherwise.
|
||||
// isEnabled in pkg/spec/src/web-runtime.ts answers the same two ways.
|
||||
function isEnabled(el) {
|
||||
if (el.disabled) return false;
|
||||
return el.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
function isEditableElement(el) {
|
||||
if (el.isContentEditable) return true;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
@@ -383,10 +408,28 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
if (tag === 'input') return !NON_TEXT_INPUT_TYPES.includes((el.type || '').toLowerCase());
|
||||
return false;
|
||||
}
|
||||
const clickableSet = new Set(document.querySelectorAll(
|
||||
'a, button, input, select, textarea, [role="button"], [onclick]'));
|
||||
const editableSet = new Set(Array.from(
|
||||
document.querySelectorAll('input, textarea, [contenteditable]')).filter(isEditableElement));
|
||||
// Shadow roots are part of the page a user sees, so they are part of the page
|
||||
// we enumerate. Compose for Web mounts its canvas AND its accessibility tree
|
||||
// inside a shadow root on the mount element, so a light-DOM-only walk reports
|
||||
// four nodes for a whole app and offers no action on any of them.
|
||||
function deepQuery(sel) {
|
||||
const out = [];
|
||||
const visit = (root) => {
|
||||
for (const el of root.querySelectorAll(sel)) out.push(el);
|
||||
for (const el of root.querySelectorAll('*')) if (el.shadowRoot) visit(el.shadowRoot);
|
||||
};
|
||||
visit(document);
|
||||
return out;
|
||||
}
|
||||
const TAPPABLE_ROLES = [
|
||||
'button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'option',
|
||||
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem'];
|
||||
const clickableSet = new Set(deepQuery(
|
||||
'a, button, input, select, textarea, ' +
|
||||
TAPPABLE_ROLES.map(role => '[role="' + role + '"]').join(', ') +
|
||||
', [onclick]'));
|
||||
const editableSet = new Set(deepQuery(
|
||||
'input, textarea, [contenteditable]').filter(isEditableElement));
|
||||
function buildTree(el, isRoot) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const attrs = {};
|
||||
@@ -414,6 +457,14 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
const isClickable = clickableSet.has(el);
|
||||
const isEditable = editableSet.has(el);
|
||||
const children = [];
|
||||
// Shadow content first, then light children: the shadow tree is what the
|
||||
// host actually renders, and targetElements in web-runtime.ts walks the same
|
||||
// order, which is the order the two enumerations are compared in.
|
||||
if (el.shadowRoot) {
|
||||
for (const child of el.shadowRoot.children) {
|
||||
children.push(buildTree(child, false));
|
||||
}
|
||||
}
|
||||
for (const child of el.children) {
|
||||
if (child.tagName === 'HEAD') continue;
|
||||
children.push(buildTree(child, false));
|
||||
@@ -422,7 +473,7 @@ func (d *Driver) Hierarchy(ctx context.Context) (string, error) {
|
||||
attributes: attrs,
|
||||
children: children,
|
||||
clickable: isClickable || null,
|
||||
enabled: (!el.disabled) || null,
|
||||
enabled: isEnabled(el) || null,
|
||||
focused: document.activeElement === el || null,
|
||||
checked: el.checked || null,
|
||||
selected: el.selected || null,
|
||||
@@ -498,10 +549,138 @@ func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error {
|
||||
// domQuietPeriod is how long the DOM must stop changing before the page counts
|
||||
// as settled. Compose for Web syncs its accessibility DOM off the frame loop:
|
||||
// measured at ~136 ms behind an InputText on the folio wasm build, so waiting
|
||||
// for frames alone (~16 ms each) returns while the app still reports the old
|
||||
// text, and the next step types into a field it believes is still empty.
|
||||
const domQuietPeriod = 150 * time.Millisecond
|
||||
|
||||
// transitionSettlePeriod is how much longer the settle waits for a route
|
||||
// transition to finish once the DOM has gone quiet. A canvas app's cross-fade
|
||||
// is invisible to a mutation observer: Compose splices the incoming screen's
|
||||
// accessibility nodes in when the animation STARTS and removes the outgoing
|
||||
// screen's when it ends, and nothing in between touches the DOM, so the tree
|
||||
// sits byte-identical (and quiet) with both routes live for the whole
|
||||
// animation. Settling on quiet alone returns there, and the next step then
|
||||
// verifies a tree that names the screen the app is leaving: on the folio wasm
|
||||
// build a submit that landed on Home was recorded as still being on the
|
||||
// transaction screen, so a property gated on where the action landed read the
|
||||
// wrong route and went vacuous. The wait is bounded so a page that genuinely
|
||||
// shows two *Screen ids at rest costs this much per step and no more.
|
||||
const transitionSettlePeriod = 800 * time.Millisecond
|
||||
|
||||
// settleReturnMargin is what WaitForIdle holds back from the caller's timeout,
|
||||
// so returning late by our own doing surfaces as a settled page rather than a
|
||||
// context cancellation.
|
||||
const settleReturnMargin = 100 * time.Millisecond
|
||||
|
||||
// settleScanMargin covers the in-page work the two waits do not themselves
|
||||
// account for: liveScreens() walks the document and every shadow root on each
|
||||
// 16 ms poll, and the whole script costs one CDP round trip.
|
||||
const settleScanMargin = 250 * time.Millisecond
|
||||
|
||||
// MinIdleTimeout is the shortest timeout WaitForIdle can be handed and still
|
||||
// spend the waits it is built from: the DOM quiet period, the route-transition
|
||||
// window that only opens once that quiet period has elapsed, and the second
|
||||
// quiet period the transition's own closing mutation starts. A caller that
|
||||
// passes less caps the settle below its own budget, and the step then samples a
|
||||
// page that is still mid-transition - which is the exact failure the transition
|
||||
// wait exists to prevent. internal/runner raises a shorter caller timeout to
|
||||
// this value.
|
||||
func (d *Driver) MinIdleTimeout() time.Duration {
|
||||
return 2*domQuietPeriod + transitionSettlePeriod +
|
||||
settleScanMargin + settleReturnMargin
|
||||
}
|
||||
|
||||
func (d *Driver) WaitForIdle(ctx context.Context, timeout time.Duration) error {
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
return chromedp.Run(runCtx, chromedp.WaitReady("body", chromedp.ByQuery))
|
||||
// Leave the caller's deadline some room: returning late by our own doing
|
||||
// would surface as a context cancellation instead of a settled page.
|
||||
budget := max(timeout-settleReturnMargin, domQuietPeriod)
|
||||
script := fmt.Sprintf(settleScript,
|
||||
domQuietPeriod.Milliseconds(),
|
||||
budget.Milliseconds(),
|
||||
transitionSettlePeriod.Milliseconds(),
|
||||
)
|
||||
return chromedp.Run(runCtx,
|
||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||
chromedp.Evaluate(script, nil, awaitPromise),
|
||||
)
|
||||
}
|
||||
|
||||
// liveScreensFunction defines liveScreens(), the page-side count of live ids
|
||||
// ending in "Screen". More than one is a route transition in flight: the same
|
||||
// rule the tree parser applies (Transitional in internal/hierarchy), so the
|
||||
// driver and the runner agree on what a settled route looks like. It descends
|
||||
// shadow roots because a canvas app keeps its whole accessibility tree inside
|
||||
// one.
|
||||
const liveScreensFunction = `
|
||||
const liveScreens = () => {
|
||||
let count = 0;
|
||||
const visit = (root) => {
|
||||
count += root.querySelectorAll('[id$="Screen"]').length;
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
if (element.shadowRoot) visit(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
visit(document);
|
||||
return count;
|
||||
};`
|
||||
|
||||
// settleScript resolves once the document has gone quiet for %d ms and is not
|
||||
// mid route transition, or after %d ms whatever happens; the transition wait
|
||||
// itself gives up after %d ms. Shadow roots get their own observer: a canvas
|
||||
// app keeps its whole accessibility tree inside one, and mutations there do not
|
||||
// reach an observer on the document.
|
||||
//
|
||||
// The transition window opens when the quiet period ends, not when the script
|
||||
// starts. Anchored at the start it is already spent by the time the check can
|
||||
// first run on any page that keeps mutating for longer than the window, so the
|
||||
// wait resolves immediately with both routes still live - the mid-transition
|
||||
// return this whole wait exists to prevent. Each mutation reopens it, and the
|
||||
// budget above bounds the total either way.
|
||||
const settleScript = `
|
||||
new Promise(resolve => {
|
||||
const quietMillis = %d, budgetMillis = %d, transitionMillis = %d;
|
||||
const observers = [];
|
||||
let transitionDeadline = 0;
|
||||
let timer = null;
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
for (const observer of observers) observer.disconnect();
|
||||
resolve();
|
||||
};
|
||||
` + liveScreensFunction + `
|
||||
const quiet = () => {
|
||||
if (transitionDeadline === 0) transitionDeadline = Date.now() + transitionMillis;
|
||||
if (liveScreens() > 1 && Date.now() < transitionDeadline) {
|
||||
timer = setTimeout(quiet, 16);
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
};
|
||||
const restart = () => {
|
||||
clearTimeout(timer);
|
||||
transitionDeadline = 0;
|
||||
timer = setTimeout(quiet, quietMillis);
|
||||
};
|
||||
const watch = (root) => {
|
||||
const observer = new MutationObserver(restart);
|
||||
observer.observe(root, {subtree: true, childList: true, attributes: true, characterData: true});
|
||||
observers.push(observer);
|
||||
for (const element of root.querySelectorAll('*')) {
|
||||
if (element.shadowRoot) watch(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
watch(document);
|
||||
setTimeout(finish, budgetMillis);
|
||||
restart();
|
||||
})`
|
||||
|
||||
func awaitPromise(params *runtime.EvaluateParams) *runtime.EvaluateParams {
|
||||
return params.WithAwaitPromise(true)
|
||||
}
|
||||
|
||||
func (d *Driver) Health(_ context.Context) (driver.Health, error) {
|
||||
@@ -596,12 +775,21 @@ func (d *Driver) InstallBundle(ctx context.Context, source []byte) error {
|
||||
|
||||
// EvaluateExtractors invokes the bundle-installed extractor table and returns
|
||||
// each extractor's JSON-encoded current value keyed by its registration index.
|
||||
//
|
||||
// The read waits out a route transition first, bounded by
|
||||
// transitionSettlePeriod. The hierarchy fetch already re-fetches a transitional
|
||||
// tree (fetchSyncedState in internal/runner); without the same rule here the
|
||||
// two halves of one step describe different moments, and the spec's own
|
||||
// extractors are the half that loses: on the folio wasm build the extractors
|
||||
// sampled mid cross-fade and reported the route the app was leaving, so a
|
||||
// property gated on where the action landed skipped the only step that action
|
||||
// could be judged on.
|
||||
func (d *Driver) EvaluateExtractors(ctx context.Context) (map[int]json.RawMessage, error) {
|
||||
const script = `JSON.stringify(window.__sanderlingExtractors__ ? window.__sanderlingExtractors__() : {})`
|
||||
script := fmt.Sprintf(extractorScript, transitionSettlePeriod.Milliseconds())
|
||||
var encoded string
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded)); err != nil {
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &encoded, awaitPromise)); err != nil {
|
||||
return nil, fmt.Errorf("evaluate extractors: %w", err)
|
||||
}
|
||||
if encoded == "" || encoded == "{}" {
|
||||
@@ -612,16 +800,90 @@ func (d *Driver) EvaluateExtractors(ctx context.Context) (map[int]json.RawMessag
|
||||
return nil, fmt.Errorf("decode extractor map: %w", err)
|
||||
}
|
||||
result := make(map[int]json.RawMessage, len(stringMap))
|
||||
for key, value := range stringMap {
|
||||
for key, entry := range stringMap {
|
||||
index, err := strconv.Atoi(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("non-integer extractor key %q", key)
|
||||
}
|
||||
result[index] = value
|
||||
reading, err := extractorReading(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extractor %d: %w", index, err)
|
||||
}
|
||||
result[index] = reading
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractorReading unwraps one entry of the page's extractor table. The page
|
||||
// wraps every reading in a {"value": ...} envelope (evaluateExtractors in
|
||||
// pkg/spec/src/web-runtime.ts) because JSON has no undefined: an absent `value`
|
||||
// is the getter returning undefined, and returning it as an empty payload is
|
||||
// what makes the goja host record undefined too. Reading it as JSON null would
|
||||
// claim the getter returned null, so `x.current === undefined` would answer one
|
||||
// thing on native and another on web.
|
||||
func extractorReading(entry json.RawMessage) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal(entry, &envelope); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"reading %s is not a {\"value\"} envelope; the page and the host are "+
|
||||
"running different bundles: %w", entry, err)
|
||||
}
|
||||
return envelope.Value, nil
|
||||
}
|
||||
|
||||
// SetLastAction installs the previous step's action as state.lastAction inside
|
||||
// the page runtime. The page cannot derive it: only the runner knows which
|
||||
// action was actually applied. Without this call every web state.lastAction is
|
||||
// null, so a property gated on what the last action did is vacuously true and
|
||||
// reports a green run while checking nothing.
|
||||
//
|
||||
// The call is deliberately unguarded. A `setter && setter(...)` form evaluates
|
||||
// to undefined on a page whose runtime does not define the setter, and chromedp
|
||||
// reports that as success, so "the page cannot accept lastAction" would be
|
||||
// indistinguishable from "installed". That page is reachable: a run resolving
|
||||
// its web runtime from an older published @sanderling/spec would silently no-op
|
||||
// every step. Unguarded, the missing global throws and the run fails loudly.
|
||||
func (d *Driver) SetLastAction(ctx context.Context, encoded json.RawMessage) error {
|
||||
payload := strings.TrimSpace(string(encoded))
|
||||
if payload == "" {
|
||||
payload = "null"
|
||||
}
|
||||
script := fmt.Sprintf(`window.__sanderlingSetLastAction__(%s)`, payload)
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, nil)); err != nil {
|
||||
return fmt.Errorf("set last action: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractorScript resolves the extractor table once the page is not mid route
|
||||
// transition, giving up on that wait after %d ms.
|
||||
//
|
||||
// A missing table rejects rather than reporting {}, for the same reason
|
||||
// SetLastAction no longer guards its call: an empty override map is what a
|
||||
// spec with no extractors returns, so the guarded form made "this page has no
|
||||
// sanderling runtime" read as a normal step whose properties then ran on
|
||||
// goja's dump-derived values instead of the page's.
|
||||
const extractorScript = `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + %d;` + liveScreensFunction + `
|
||||
const read = () => {
|
||||
if (liveScreens() > 1 && Date.now() < deadline) {
|
||||
setTimeout(read, 16);
|
||||
return;
|
||||
}
|
||||
if (typeof window.__sanderlingExtractors__ !== "function") {
|
||||
reject(new Error("__sanderlingExtractors__ is not installed in the page"));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.stringify(window.__sanderlingExtractors__()));
|
||||
};
|
||||
read();
|
||||
})`
|
||||
|
||||
// NextActionFromV8 invokes the bundle-installed action generator and returns
|
||||
// the resulting Action JSON. Returns an empty json.RawMessage when the
|
||||
// generator declines to act this tick.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -474,3 +475,493 @@ func TestLaunch_KeepsBrowserAliveAfterCallerContextEnds(t *testing.T) {
|
||||
t.Fatalf("second Launch after the first caller context ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputText_ReplacesTextInsideAShadowRoot pins ReplacesTextOnInput's promise
|
||||
// on the shape a canvas app actually has. Compose for Web draws its text fields
|
||||
// on a canvas and routes typing through a hidden <input> INSIDE the shadow root
|
||||
// it mounts, and document.activeElement stops at a shadow boundary: it names the
|
||||
// host. The select-all therefore ran against a <div> with no select(), every
|
||||
// InputText appended to the last, and a fuzzer typing twice into one field built
|
||||
// up text it could never clear (observed on the folio wasm build as
|
||||
// "0.0000001" -> "0.0000001\t-1").
|
||||
func TestInputText_ReplacesTextInsideAShadowRoot(t *testing.T) {
|
||||
const page = `<body><div id="app"></div><script>
|
||||
const root = document.getElementById("app").attachShadow({mode: "open"});
|
||||
root.innerHTML = ` + "`" + `
|
||||
<style>
|
||||
#surface { position: absolute; left: 0; top: 0; }
|
||||
#a11y { position: absolute; left: 0; top: 0; pointer-events: none; }
|
||||
#proxy { position: absolute; left: -9999px; }
|
||||
</style>
|
||||
<canvas id="surface" width="300" height="200"></canvas>
|
||||
<div id="a11y"><div id="field">-</div></div>
|
||||
<input id="proxy" type="text">` + "`" + `;
|
||||
const proxy = root.getElementById("proxy");
|
||||
const field = root.getElementById("field");
|
||||
// The canvas owns the pointer (the a11y overlay is pointer-events: none)
|
||||
// and hands focus to the proxy, exactly as a canvas app does.
|
||||
root.getElementById("surface").addEventListener("click", function () { proxy.focus(); });
|
||||
proxy.addEventListener("input", function () { field.textContent = proxy.value; });
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := d.Tap(ctx, 40, 40); err != nil {
|
||||
t.Fatalf("Tap: %v", err)
|
||||
}
|
||||
if err := d.InputText(ctx, "alpha"); err != nil {
|
||||
t.Fatalf("InputText: %v", err)
|
||||
}
|
||||
if err := d.InputText(ctx, "beta"); err != nil {
|
||||
t.Fatalf("InputText: %v", err)
|
||||
}
|
||||
|
||||
var shown string
|
||||
script := `document.getElementById("app").shadowRoot.getElementById("field").textContent`
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
|
||||
t.Fatalf("read field: %v", err)
|
||||
}
|
||||
if shown != "beta" {
|
||||
t.Errorf("field holds %q, want %q; the second InputText appended instead of replacing", shown, "beta")
|
||||
}
|
||||
|
||||
if err := d.EraseText(ctx, len("beta")); err != nil {
|
||||
t.Fatalf("EraseText: %v", err)
|
||||
}
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
|
||||
t.Fatalf("read field: %v", err)
|
||||
}
|
||||
if shown != "" {
|
||||
t.Errorf("field holds %q after EraseText, want empty", shown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHierarchy_ScreenFallsBackToThePathname pins the route the goja host reads
|
||||
// off the dump. Reading location.hash alone reported "/" on every step of a
|
||||
// path-routed SPA (react-router's BrowserRouter, which the replay UI itself
|
||||
// uses), so every screen looked like the same screen and no route-scoped
|
||||
// property or action could tell them apart.
|
||||
func TestHierarchy_ScreenFallsBackToThePathname(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<body><div id="app">app</div></body>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"path-routed", "/runs/20260101-120000/steps/7", "/runs/20260101-120000/steps/7"},
|
||||
{"hash wins when present", "/runs/1#/detail", "/detail"},
|
||||
{"root", "/", "/"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL+testCase.path, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
dump, err := d.Hierarchy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Hierarchy: %v", err)
|
||||
}
|
||||
var root struct {
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(dump), &root); err != nil {
|
||||
t.Fatalf("unmarshal hierarchy: %v", err)
|
||||
}
|
||||
if got := root.Attributes["sanderling-screen"]; got != testCase.want {
|
||||
t.Errorf("sanderling-screen: got %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_WaitsForWorkTheActionKickedOff pins the settle the runner
|
||||
// relies on between acting and observing. WaitForIdle used to return the moment
|
||||
// <body> existed, which is true before the app has reacted at all: measured on
|
||||
// the folio wasm build, Compose's accessibility DOM lands ~136 ms after an
|
||||
// InputText, so the next step read the pre-action text and typed into a field
|
||||
// it believed was still empty.
|
||||
func TestWaitForIdle_WaitsForWorkTheActionKickedOff(t *testing.T) {
|
||||
const page = `<body><div id="app"></div><script>
|
||||
const root = document.getElementById("app").attachShadow({mode: "open"});
|
||||
root.innerHTML = '<button id="go" style="width:200px;height:80px">go</button><div id="out">pending</div>';
|
||||
root.getElementById("go").addEventListener("click", function () {
|
||||
setTimeout(function () { root.getElementById("out").textContent = "settled"; }, 100);
|
||||
});
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := d.Tap(ctx, 40, 40); err != nil {
|
||||
t.Fatalf("Tap: %v", err)
|
||||
}
|
||||
if err := d.WaitForIdle(ctx, time.Second); err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
|
||||
var shown string
|
||||
script := `document.getElementById("app").shadowRoot.getElementById("out").textContent`
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if shown != "settled" {
|
||||
t.Errorf("observed %q; WaitForIdle returned before the tap's own work landed", shown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_ReturnsOnABusyPage is the other half: a page that never stops
|
||||
// mutating (an animation, a polling widget) must not hold the step loop open.
|
||||
func TestWaitForIdle_ReturnsOnABusyPage(t *testing.T) {
|
||||
const page = `<body><div id="tick">0</div><script>
|
||||
let n = 0;
|
||||
setInterval(function () { document.getElementById("tick").textContent = String(++n); }, 15);
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if err := d.WaitForIdle(ctx, time.Second); err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("WaitForIdle took %s on a busy page; it must return inside its budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_WaitsOutARouteTransition covers the settle case a mutation
|
||||
// observer cannot see. A canvas app splices the incoming screen's
|
||||
// accessibility nodes in when its cross-fade STARTS and drops the outgoing
|
||||
// screen's when it ends; between those two mutations the DOM is quiet with both
|
||||
// routes live. Returning there hands the next step a tree naming the screen the
|
||||
// app is leaving, which on the folio wasm build recorded a submit that had
|
||||
// landed on Home as still being on the transaction screen: the route gate of an
|
||||
// action-gated property then skipped the very step the action landed on.
|
||||
//
|
||||
// The page keeps mutating for 300 ms after the route splice, and the settle
|
||||
// runs on the timeout production hands it (MinIdleTimeout). Both details are
|
||||
// load-bearing. A quiet page reaches the transition check immediately, so it
|
||||
// passes whether the transition window is anchored at the script start or at
|
||||
// the end of the quiet period; churn is what pushes the check past a
|
||||
// start-anchored deadline, which then finishes at once with two live screens.
|
||||
// And a caller timeout below MinIdleTimeout cuts the whole settle off before
|
||||
// the transition window can be spent, which is the same bug from the other end.
|
||||
func TestWaitForIdle_WaitsOutARouteTransition(t *testing.T) {
|
||||
const page = `<body><div id="app"></div><script>
|
||||
const root = document.getElementById("app").attachShadow({mode: "open"});
|
||||
root.innerHTML = '<button id="go" style="width:200px;height:80px">go</button>' +
|
||||
'<div id="LedgerScreen">ledger</div><div id="spinner">0</div>';
|
||||
root.getElementById("go").addEventListener("click", function () {
|
||||
const incoming = document.createElement("div");
|
||||
incoming.id = "HomeScreen";
|
||||
incoming.textContent = "home";
|
||||
root.appendChild(incoming);
|
||||
let frame = 0;
|
||||
const churn = setInterval(function () {
|
||||
root.getElementById("spinner").textContent = String(++frame);
|
||||
}, 30);
|
||||
setTimeout(function () { clearInterval(churn); }, 300);
|
||||
setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 1000);
|
||||
});
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := d.Tap(ctx, 40, 40); err != nil {
|
||||
t.Fatalf("Tap: %v", err)
|
||||
}
|
||||
if err := d.WaitForIdle(ctx, d.MinIdleTimeout()); err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
|
||||
var live []string
|
||||
script := `Array.from(document.getElementById("app").shadowRoot
|
||||
.querySelectorAll('[id$="Screen"]')).map(e => e.id)`
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &live)); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if len(live) != 1 || live[0] != "HomeScreen" {
|
||||
t.Errorf("live screens after the settle = %v, want [HomeScreen]; WaitForIdle "+
|
||||
"returned mid-transition, so the next step verifies the outgoing route", live)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForIdle_BoundsTheTransitionWait is the other half of the transition
|
||||
// wait: a page that shows two *Screen ids at rest is not mid-transition, it
|
||||
// just matches the heuristic, and it must cost one bounded wait rather than the
|
||||
// whole step budget on every step.
|
||||
func TestWaitForIdle_BoundsTheTransitionWait(t *testing.T) {
|
||||
const page = `<body><div id="HomeScreen">home</div><div id="LedgerScreen">ledger</div></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if err := d.WaitForIdle(ctx, 10*time.Second); err != nil {
|
||||
t.Fatalf("WaitForIdle: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > transitionSettlePeriod+time.Second {
|
||||
t.Errorf("WaitForIdle took %s on a page with two resting screens; the "+
|
||||
"transition wait must be bounded by %s", elapsed, transitionSettlePeriod)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateExtractors_WaitsOutARouteTransition covers the other sampler. A
|
||||
// step reads the page twice: the hierarchy dump (which re-fetches while the
|
||||
// tree looks transitional) and the spec's own extractors in V8. Sampling the
|
||||
// extractors mid cross-fade reports the route the app is leaving, and an
|
||||
// action-gated property then skips the one step its action can be judged on:
|
||||
// on the folio wasm build a double-submit that landed on Home was extracted as
|
||||
// still being on the transaction screen.
|
||||
func TestEvaluateExtractors_WaitsOutARouteTransition(t *testing.T) {
|
||||
const page = `<body><div id="app"></div><script>
|
||||
const root = document.getElementById("app").attachShadow({mode: "open"});
|
||||
root.innerHTML = '<div id="LedgerScreen">ledger</div>';
|
||||
window.__sanderlingExtractors__ = function () {
|
||||
return {0: {value: Array.from(root.querySelectorAll('[id$="Screen"]')).map(e => e.id).join(",")}};
|
||||
};
|
||||
window.startTransition = function () {
|
||||
const incoming = document.createElement("div");
|
||||
incoming.id = "HomeScreen";
|
||||
root.appendChild(incoming);
|
||||
setTimeout(function () { root.getElementById("LedgerScreen").remove(); }, 400);
|
||||
};
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.startTransition()`, nil)); err != nil {
|
||||
t.Fatalf("start transition: %v", err)
|
||||
}
|
||||
|
||||
values, err := d.EvaluateExtractors(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateExtractors: %v", err)
|
||||
}
|
||||
if got := string(values[0]); got != `"HomeScreen"` {
|
||||
t.Errorf("extractor read %s, want \"HomeScreen\"; the extractors sampled "+
|
||||
"mid-transition, so the spec sees the route the app is leaving", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLastAction_ReportsAPageThatCannotTakeIt covers the install the whole
|
||||
// web path's action-gated properties hang off. A page without the setter is
|
||||
// reachable: internal/testrun resolves the web runtime from
|
||||
// node_modules/@sanderling/spec when no sibling checkout is present, and an
|
||||
// older published runtime does not define it. Guarded as
|
||||
// `setter && setter(...)`, that page returns undefined and chromedp reports
|
||||
// success, so every step silently no-ops and every property gated on the last
|
||||
// action goes vacuously true - a green run that checked nothing.
|
||||
func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) {
|
||||
const withSetter = `<body><script>
|
||||
window.__lastActionSeen = null;
|
||||
window.__sanderlingSetLastAction__ = function (value) { window.__lastActionSeen = value; };
|
||||
</script></body>`
|
||||
const withoutSetter = `<body><div id="app">no sanderling runtime here</div></body>`
|
||||
pages := map[string]string{"/with": withSetter, "/without": withoutSetter}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(pages[r.URL.Path]))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Launch(ctx, server.URL+"/with", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
action := json.RawMessage(`{"kind":"Tap","on":"id:TxnSubmit"}`)
|
||||
if err := d.SetLastAction(ctx, action); err != nil {
|
||||
t.Fatalf("SetLastAction on a page that defines the setter: %v", err)
|
||||
}
|
||||
var seen map[string]string
|
||||
if err := chromedp.Run(d.tabCtx,
|
||||
chromedp.Evaluate(`window.__lastActionSeen`, &seen)); err != nil {
|
||||
t.Fatalf("read installed action: %v", err)
|
||||
}
|
||||
if seen["on"] != "id:TxnSubmit" {
|
||||
t.Errorf("the page received %v, want the action the runner applied", seen)
|
||||
}
|
||||
|
||||
if err := d.Launch(ctx, server.URL+"/without", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := d.SetLastAction(ctx, action); err == nil {
|
||||
t.Error("SetLastAction reported success on a page with no setter; " +
|
||||
"a runtime that cannot take lastAction is indistinguishable from one that did")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateExtractors_ReportsAMissingTable is the same failure on the other
|
||||
// sampler. An empty override map is what a spec with no extractors returns, so
|
||||
// treating a missing table as {} makes "this page has no sanderling runtime"
|
||||
// read as an ordinary step - and the verifier then judges the run on goja's
|
||||
// dump-derived values while believing they came from the page.
|
||||
func TestEvaluateExtractors_ReportsAMissingTable(t *testing.T) {
|
||||
const page = `<body><div id="app">no sanderling runtime here</div></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
values, err := d.EvaluateExtractors(ctx)
|
||||
if err == nil {
|
||||
t.Errorf("EvaluateExtractors returned %v and no error on a page with no "+
|
||||
"extractor table; a page that cannot be read must not read as empty", values)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateExtractors_KeepsUndefinedApartFromNull covers the wire the page's
|
||||
// readings cross. JSON has no undefined, so the web runtime wraps each reading
|
||||
// in a {value} envelope: written straight into the table, an extractor that
|
||||
// returned undefined lost its whole index to JSON.stringify and the host kept
|
||||
// goja's dump-derived reading for it while the rest held the page's. An absent
|
||||
// value has to arrive as an empty payload, which is what makes the verifier
|
||||
// record undefined (the value the native host records for the same getter);
|
||||
// arriving as JSON null would claim the getter returned null.
|
||||
func TestEvaluateExtractors_KeepsUndefinedApartFromNull(t *testing.T) {
|
||||
const page = `<body><script>
|
||||
window.__sanderlingExtractors__ = function () {
|
||||
return {0: {}, 1: {value: null}, 2: {value: {balance: 7}}};
|
||||
};
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
values, err := d.EvaluateExtractors(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateExtractors: %v", err)
|
||||
}
|
||||
if len(values) != 3 {
|
||||
t.Fatalf("the page reported 3 readings, %d survived the wire: %v", len(values), values)
|
||||
}
|
||||
if got := values[0]; len(got) != 0 {
|
||||
t.Errorf("the undefined reading arrived as %s, want an empty payload; "+
|
||||
"the verifier records anything else as a value the getter never returned", got)
|
||||
}
|
||||
if got := string(values[1]); got != "null" {
|
||||
t.Errorf("the null reading arrived as %s, want null", got)
|
||||
}
|
||||
if got := string(values[2]); got != `{"balance":7}` {
|
||||
t.Errorf("the object reading arrived as %s, want {\"balance\":7}", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateExtractors_RejectsAnUnenvelopedReading is the loud failure a page
|
||||
// running an older @sanderling/spec produces. Its readings are bare values, and
|
||||
// a bare value is indistinguishable from a reading whose getter returned that
|
||||
// value, so accepting them silently puts the two engines on different bundles.
|
||||
func TestEvaluateExtractors_RejectsAnUnenvelopedReading(t *testing.T) {
|
||||
const page = `<body><script>
|
||||
window.__sanderlingExtractors__ = function () { return {0: "home"}; };
|
||||
</script></body>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
values, err := d.EvaluateExtractors(ctx)
|
||||
if err == nil {
|
||||
t.Fatalf("EvaluateExtractors accepted %v from a page whose readings are not "+
|
||||
"enveloped; the page and the host are running different bundles", values)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "different bundles") {
|
||||
t.Errorf("EvaluateExtractors failed with %q, want it to name the bundle mismatch", err)
|
||||
}
|
||||
}
|
||||
@@ -60,30 +60,41 @@ type factRow struct {
|
||||
facts elementFacts
|
||||
}
|
||||
|
||||
// parityPages are the pages both producers are compared on. Each one must
|
||||
// exercise every fact both ways on its own (requireBothPolarities), so adding a
|
||||
// page never weakens the comparison. fact-parity-shadow.html is shaped like a
|
||||
// Compose for Web app: the whole UI lives inside a shadow root, which both
|
||||
// producers have to descend into or they enumerate one node for an entire app.
|
||||
var parityPages = []string{"fact-parity.html", "fact-parity-shadow.html"}
|
||||
|
||||
func TestHierarchy_DerivesTheSameFactsAsTheWebRuntime(t *testing.T) {
|
||||
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL+"/fact-parity.html", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
for _, page := range parityPages {
|
||||
t.Run(page, func(t *testing.T) {
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, server.URL+"/"+page, false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
dump, err := d.Hierarchy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Hierarchy: %v", err)
|
||||
}
|
||||
fromDump := factsFromHierarchyDump(t, dump)
|
||||
fromWebRuntime := factsFromWebRuntime(ctx, t, d)
|
||||
dump, err := d.Hierarchy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Hierarchy: %v", err)
|
||||
}
|
||||
fromDump := factsFromHierarchyDump(t, dump)
|
||||
fromWebRuntime := factsFromWebRuntime(ctx, t, d)
|
||||
|
||||
requireEveryElementNamed(t, "the hierarchy dump", fromDump)
|
||||
requireEveryElementNamed(t, "the web runtime", fromWebRuntime)
|
||||
requireBothPolarities(t, fromWebRuntime)
|
||||
compareEnumeratedElements(t, fromDump, fromWebRuntime)
|
||||
compareDerivedFacts(t, fromDump, fromWebRuntime)
|
||||
requireEveryElementNamed(t, "the hierarchy dump", fromDump)
|
||||
requireEveryElementNamed(t, "the web runtime", fromWebRuntime)
|
||||
requireBothPolarities(t, fromWebRuntime)
|
||||
compareEnumeratedElements(t, fromDump, fromWebRuntime)
|
||||
compareDerivedFacts(t, fromDump, fromWebRuntime)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// factsFromHierarchyDump reads the dump the way the goja host does: parse it,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html id="page">
|
||||
<head id="page-head">
|
||||
<meta id="page-charset" charset="utf-8" />
|
||||
<title id="page-title">fact parity: shadow dom</title>
|
||||
</head>
|
||||
<body id="page-body">
|
||||
<!-- Shaped like Compose for Web: one mount element owning a shadow root
|
||||
that holds the canvas plus an accessibility overlay whose nodes are
|
||||
pointer-events: none, so taps fall through to the canvas. A producer
|
||||
that stops at the shadow boundary reports this whole app as one node. -->
|
||||
<div id="host">
|
||||
<div id="host-light-child">light child, no slot to render into</div>
|
||||
</div>
|
||||
<script id="page-script">
|
||||
const root = document.getElementById("host").attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style id="shadow-style">
|
||||
#shadow-surface { position: absolute; inset: 0; }
|
||||
#shadow-overlay { position: absolute; inset: 0; pointer-events: none; }
|
||||
#shadow-scroller { width: 320px; height: 120px; overflow: auto; }
|
||||
#shadow-scroller-content { height: 600px; }
|
||||
</style>
|
||||
<canvas id="shadow-surface" width="420" height="640"></canvas>
|
||||
<div id="shadow-overlay">
|
||||
<button id="shadow-save">save</button>
|
||||
<button id="shadow-cancel" disabled>cancel</button>
|
||||
<input id="shadow-amount" type="text" value="10" />
|
||||
<div id="shadow-plain">plain</div>
|
||||
</div>
|
||||
<div id="shadow-scroller"><div id="shadow-scroller-content"></div></div>`;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -56,6 +56,23 @@
|
||||
<textarea id="notes"></textarea>
|
||||
<div id="bio" contenteditable="true">bio</div>
|
||||
<div id="menu" role="button">menu</div>
|
||||
<!-- One element per ARIA role both producers resolve as clickable. A role
|
||||
covered on one side only makes that control reachable for one host,
|
||||
which is how the whole set is kept honest: role="option" is the shape
|
||||
the replay UI gives its step rows. -->
|
||||
<div id="role-link" role="link">link</div>
|
||||
<div id="role-checkbox" role="checkbox">checkbox</div>
|
||||
<div id="role-radio" role="radio">radio</div>
|
||||
<div id="role-switch" role="switch">switch</div>
|
||||
<div id="role-tab" role="tab">tab</div>
|
||||
<div id="role-menuitem" role="menuitem">menu item</div>
|
||||
<div id="role-menuitemcheckbox" role="menuitemcheckbox">menu item checkbox</div>
|
||||
<div id="role-menuitemradio" role="menuitemradio">menu item radio</div>
|
||||
<div id="role-treeitem" role="treeitem">tree item</div>
|
||||
<ul id="role-listbox" role="listbox">
|
||||
<li id="role-option" role="option">option</li>
|
||||
<li id="role-option-disabled" role="option" aria-disabled="true">disabled option</li>
|
||||
</ul>
|
||||
<div id="attribute-click" onclick="void 0">attribute click</div>
|
||||
<div id="delegating-root">delegating root</div>
|
||||
<div id="plain">plain</div>
|
||||
|
||||
@@ -109,6 +109,13 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
d.restart = d.respawnDevice
|
||||
d.processContext, d.processCancel = context.WithCancel(ctx)
|
||||
|
||||
lock, err := acquireDeviceLock(d.udid)
|
||||
if err != nil {
|
||||
d.processCancel()
|
||||
return nil, err
|
||||
}
|
||||
d.deviceLock = lock
|
||||
|
||||
if err := d.bringUpDevice(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -42,6 +42,17 @@ const runnerStartupTimeout = 120 * time.Second
|
||||
// before it is killed. A variable so the kill-escalation test can shrink it.
|
||||
var shutdownGrace = 15 * time.Second
|
||||
|
||||
// launchTimeout bounds a single app lifecycle RPC. The runner serves lifecycle
|
||||
// inside its XCTest session, and a launch the simulator rejects sends that
|
||||
// session down a recovery chain (a 120s accessibility wait, a spindump, then an
|
||||
// idle wait) that answers minutes late or never. Callers reach Launch with an
|
||||
// undeadlined context, since it runs before the run's duration clock starts, so
|
||||
// the bound has to come from here or a wedged session hangs the run with no
|
||||
// trace, no error, and no end. Kept under runnerStartupTimeout: launching an
|
||||
// app inside a live session must cost less than cold-starting that session.
|
||||
// A variable so the timeout test can shrink it.
|
||||
var launchTimeout = 90 * time.Second
|
||||
|
||||
// longPressHoldMilliseconds is how long LongPress holds the finger down.
|
||||
const longPressHoldMilliseconds = 600
|
||||
|
||||
@@ -141,6 +152,34 @@ type Driver struct {
|
||||
// the moment startup finishes.
|
||||
processContext context.Context
|
||||
processCancel context.CancelFunc
|
||||
|
||||
// deviceLock is the exclusive claim on the target, held for the driver's
|
||||
// whole life and released by Close.
|
||||
deviceLock io.Closer
|
||||
}
|
||||
|
||||
// acquireDeviceLock takes an exclusive advisory lock on the target so only one
|
||||
// run drives it at a time. Two runs on one device interleave app lifecycle: the
|
||||
// second run's uninstall and reinstall land under the first's live automation
|
||||
// session, leaving its app proxies bound to a bundle the simulator no longer
|
||||
// knows, and every later snapshot and launch on that session stalls. Failing
|
||||
// fast beats recovering silently, since the other run owns the device and would
|
||||
// be corrupted either way. The lock lives on the file descriptor, so a crashed
|
||||
// run's claim is released by the kernel and never strands the device.
|
||||
func acquireDeviceLock(udid string) (io.Closer, error) {
|
||||
path := filepath.Join(os.TempDir(), "sanderling-ios-"+udid+".lock")
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open device lock %s: %w", path, err)
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf(
|
||||
"ios target %s is already driven by another sanderling run (lock %s); "+
|
||||
"wait for that run to finish or point this one at a different device with --ios-device",
|
||||
udid, path)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// New extracts the embedded companion, spawns it against the configured
|
||||
@@ -199,7 +238,15 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
driverInstance.grantPaste = driverInstance.grantPasteboardAccess
|
||||
driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx)
|
||||
|
||||
lock, err := acquireDeviceLock(driverInstance.udid)
|
||||
if err != nil {
|
||||
driverInstance.processCancel()
|
||||
return nil, err
|
||||
}
|
||||
driverInstance.deviceLock = lock
|
||||
|
||||
if err := driverInstance.bringUp(ctx); err != nil {
|
||||
driverInstance.Close()
|
||||
return nil, err
|
||||
}
|
||||
if driverInstance.hybrid {
|
||||
@@ -408,7 +455,9 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
|
||||
// Terminate first so the launch is a clean cold start regardless of the
|
||||
// app's prior state. A not-running app is not an error here.
|
||||
_ = d.withRecovery(ctx, func() error { return d.lifecycleCompanion().Terminate(ctx, d.bundleID) })
|
||||
_ = d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Terminate(callCtx, d.bundleID)
|
||||
})
|
||||
|
||||
if clearState {
|
||||
if err := d.clearAppState(ctx); err != nil {
|
||||
@@ -428,14 +477,26 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.withRecovery(ctx, func() error {
|
||||
return d.lifecycleCompanion().Launch(ctx, d.bundleID, true)
|
||||
if err := d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Launch(callCtx, d.bundleID, true)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("launch %s: %w", d.bundleID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lifecycleCall runs an app lifecycle RPC against lifecycleCompanion under a
|
||||
// launchTimeout-bounded context, with the usual one-restart recovery. The
|
||||
// companion is resolved inside the retry so a restart's replacement client
|
||||
// serves the second attempt.
|
||||
func (d *Driver) lifecycleCall(ctx context.Context, call func(context.Context, transport.Companion) error) error {
|
||||
boundedCtx, cancel := context.WithTimeout(ctx, launchTimeout)
|
||||
defer cancel()
|
||||
return d.withRecovery(boundedCtx, func() error {
|
||||
return call(boundedCtx, d.lifecycleCompanion())
|
||||
})
|
||||
}
|
||||
|
||||
// lifecycleCompanion is the transport that owns app launch and terminate: the
|
||||
// in-simulator runner when the hybrid is active, otherwise the legacy
|
||||
// companion. Lifecycle performed outside the runner's automation session
|
||||
@@ -528,7 +589,9 @@ func (d *Driver) resetDataContainer(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (d *Driver) Terminate(ctx context.Context) error {
|
||||
return d.withRecovery(ctx, func() error { return d.lifecycleCompanion().Terminate(ctx, d.bundleID) })
|
||||
return d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Terminate(callCtx, d.bundleID)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Driver) Tap(ctx context.Context, x, y int) error {
|
||||
@@ -999,6 +1062,10 @@ func (d *Driver) Close() {
|
||||
if d.processCancel != nil {
|
||||
d.processCancel()
|
||||
}
|
||||
if d.deviceLock != nil {
|
||||
_ = d.deviceLock.Close()
|
||||
d.deviceLock = nil
|
||||
}
|
||||
}
|
||||
|
||||
// stopTunnel closes the in-process usbmux forwarder on the device path. Closing
|
||||
|
||||
@@ -851,3 +851,175 @@ func (s *sequencedDumpCompanion) AccessibilityInfo(context.Context) (string, err
|
||||
*s.reads++
|
||||
return s.dumps[index], nil
|
||||
}
|
||||
|
||||
// wedgedLifecycleCompanion never answers a lifecycle RPC, standing in for a
|
||||
// runner whose XCTest session is stuck inside a rejected launch.
|
||||
type wedgedLifecycleCompanion struct {
|
||||
fakeCompanion
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (w *wedgedLifecycleCompanion) block(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-w.release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *wedgedLifecycleCompanion) Launch(ctx context.Context, _ string, _ bool) error {
|
||||
return w.block(ctx)
|
||||
}
|
||||
|
||||
func (w *wedgedLifecycleCompanion) Terminate(ctx context.Context, _ string) error {
|
||||
return w.block(ctx)
|
||||
}
|
||||
|
||||
// TestLaunchBoundsWedgedLifecycleRPC proves the launch path carries its own
|
||||
// deadline. Callers hand Launch an undeadlined context, so without one a runner
|
||||
// that never answers hangs the run forever with nothing printed.
|
||||
func TestLaunchBoundsWedgedLifecycleRPC(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedLifecycleCompanion{release: make(chan struct{})}
|
||||
defer close(companion.release)
|
||||
d := newTestDriver(companion)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Launch(context.Background(), "com.example.app", false, nil) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("wedged launch returned nil; a stuck runner must surface an error")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want a deadline-exceeded error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Launch never returned: the lifecycle RPC is unbounded, so a stuck runner hangs the run forever")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminateBoundsWedgedLifecycleRPC covers the same bound on the standalone
|
||||
// terminate, which the runner calls mid-run on an equally stuck session.
|
||||
func TestTerminateBoundsWedgedLifecycleRPC(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedLifecycleCompanion{release: make(chan struct{})}
|
||||
defer close(companion.release)
|
||||
d := newTestDriver(companion)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Terminate(context.Background()) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want a deadline-exceeded error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Terminate never returned: the lifecycle RPC is unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchLeavesATighterCallerDeadlineAlone confirms the bound narrows the
|
||||
// caller's context and never widens it, so a caller that wants to give up
|
||||
// sooner still does.
|
||||
func TestLaunchLeavesATighterCallerDeadlineAlone(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 30 * time.Second
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedLifecycleCompanion{release: make(chan struct{})}
|
||||
defer close(companion.release)
|
||||
d := newTestDriver(companion)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if err := d.Launch(ctx, "com.example.app", false, nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want a deadline-exceeded error", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("Launch took %v; the driver's bound overrode the caller's tighter deadline", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// newLockTestOptions builds New options that dial a seamed companion, so the
|
||||
// device-lock tests exercise New without spawning anything.
|
||||
func newLockTestOptions(t *testing.T, udid string) Options {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
}
|
||||
}()
|
||||
return Options{
|
||||
UniqueDeviceIdentifier: udid,
|
||||
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
|
||||
spawnChild: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
|
||||
dialCompanion: func(string) (transport.Companion, error) {
|
||||
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRejectsConcurrentRunOnSameDevice proves a second run cannot claim a
|
||||
// device the first is driving. Two runs interleave app lifecycle on one
|
||||
// simulator: the second's reinstall lands under the first's automation session
|
||||
// and wedges it. Failing fast names the contended device; the claim is released
|
||||
// on Close so the next run is not locked out.
|
||||
func TestNewRejectsConcurrentRunOnSameDevice(t *testing.T) {
|
||||
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
|
||||
udid := "LOCK-TEST-" + t.Name()
|
||||
|
||||
first, err := New(context.Background(), newLockTestOptions(t, udid))
|
||||
if err != nil {
|
||||
t.Fatalf("first New: %v", err)
|
||||
}
|
||||
|
||||
_, err = New(context.Background(), newLockTestOptions(t, udid))
|
||||
if err == nil {
|
||||
t.Fatal("second run claimed a device the first still drives; concurrent runs corrupt each other's session")
|
||||
}
|
||||
if !strings.Contains(err.Error(), udid) {
|
||||
t.Fatalf("err = %v, want it to name the contended device %s", err, udid)
|
||||
}
|
||||
|
||||
first.Close()
|
||||
third, err := New(context.Background(), newLockTestOptions(t, udid))
|
||||
if err != nil {
|
||||
t.Fatalf("device stayed locked after Close: %v", err)
|
||||
}
|
||||
third.Close()
|
||||
}
|
||||
|
||||
// TestAcquireDeviceLockKeepsDistinctDevicesIndependent guards against a lock
|
||||
// path that ignores the udid and serializes unrelated runs.
|
||||
func TestAcquireDeviceLockKeepsDistinctDevicesIndependent(t *testing.T) {
|
||||
first, err := acquireDeviceLock("LOCK-TEST-DEVICE-A")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
second, err := acquireDeviceLock("LOCK-TEST-DEVICE-B")
|
||||
if err != nil {
|
||||
t.Fatalf("a second device was refused while another was locked: %v", err)
|
||||
}
|
||||
defer second.Close()
|
||||
}
|
||||
@@ -129,6 +129,9 @@ func mapElement(element *rawElement) (treeNode, bool) {
|
||||
attributes["hintText"] = label
|
||||
} else if label != "" {
|
||||
attributes["accessibilityText"] = label
|
||||
if labelIsDisplayedText(element.Type) {
|
||||
attributes["text"] = label
|
||||
}
|
||||
}
|
||||
|
||||
enabled := element.Enabled
|
||||
@@ -150,6 +153,22 @@ func isEditable(elementType string) bool {
|
||||
return elementType == "TextArea" || elementType == "TextField"
|
||||
}
|
||||
|
||||
// labelIsDisplayedText reports whether an element type's AXLabel is the string
|
||||
// drawn on screen rather than an accessibility annotation about it. Only
|
||||
// StaticText qualifies: a text element's label IS what it renders, so it
|
||||
// belongs in `text`, matching a TextView on Android and a text node on web.
|
||||
//
|
||||
// Buttons and images are deliberately excluded even though a titled button's
|
||||
// label is also its visible title. The snapshot cannot tell that button apart
|
||||
// from an icon-only one whose label exists purely for VoiceOver, nor from a
|
||||
// container whose label is a comma-joined reading of its children ("CH,
|
||||
// Checking, $0.00, 0 transactions"). Inventing `text` for those would put
|
||||
// strings in `text` that no user can read, and would diverge from Android,
|
||||
// which leaves `text` empty and reports a contentDescription as `description`.
|
||||
func labelIsDisplayedText(elementType string) bool {
|
||||
return elementType == "StaticText"
|
||||
}
|
||||
|
||||
func stringValue(pointer *string) string {
|
||||
if pointer == nil {
|
||||
return ""
|
||||
|
||||
@@ -132,6 +132,79 @@ func TestNonEmptyValueMapsToText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticTextLabelMapsToText(t *testing.T) {
|
||||
dump := `[{"type":"StaticText","frame":{"x":0,"y":0,"width":50,"height":18},
|
||||
"AXLabel":"$239.00","AXValue":null,"enabled":true}]`
|
||||
element := parseSingle(t, dump)
|
||||
// A StaticText renders its label, so a spec reading .text must see it.
|
||||
if element.Text != "$239.00" {
|
||||
t.Fatalf("text = %q, want $239.00", element.Text)
|
||||
}
|
||||
// The raw label stays available: desc:/label:/content-desc: selectors and
|
||||
// the settle hash read it on the iOS path.
|
||||
if element.Description != "$239.00" {
|
||||
t.Fatalf("description = %q, want $239.00", element.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonTextLabelStaysDescriptionOnly(t *testing.T) {
|
||||
// An icon-only button's label is a VoiceOver annotation, and a container's
|
||||
// is a comma-joined reading of its children. Neither is on screen, so
|
||||
// neither may become text; Android reports both as description too.
|
||||
cases := []struct{ elementType, label string }{
|
||||
{"Button", "Log out"},
|
||||
{"Image", "Avatar"},
|
||||
{"Other", "CH, Checking, $0.00, 0 transactions"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
dump := `[{"type":"` + testCase.elementType + `","frame":{"x":0,"y":0,"width":10,"height":10},
|
||||
"AXLabel":"` + testCase.label + `","AXValue":null,"enabled":true}]`
|
||||
element := parseSingle(t, dump)
|
||||
if element.Text != "" {
|
||||
t.Errorf("%s text = %q, want empty", testCase.elementType, element.Text)
|
||||
}
|
||||
if element.Description != testCase.label {
|
||||
t.Errorf("%s description = %q, want %q", testCase.elementType, element.Description, testCase.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the folio Home screen as the companion actually dumps it: each
|
||||
// AccountCard is a Button carrying a merged VoiceOver label, with the name and
|
||||
// balance as StaticText siblings that the flat tree re-parents by containment.
|
||||
// Reading a card's balance is what folio's totalBalance extractor does, and it
|
||||
// read nothing on iOS until StaticText labels became text.
|
||||
func TestGoldenHomeCardBalancesAreReadableAsText(t *testing.T) {
|
||||
tree := mapAndParse(t, readDump(t, "home-cards-describe.json"), 402, 874)
|
||||
|
||||
cards := tree.FindAllNodes("id:HomeScreen > id:AccountCard")
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("account cards = %d, want 2", len(cards))
|
||||
}
|
||||
want := []struct{ name, balance string }{{"Checking", "$12.34"}, {"Savings", "$500.00"}}
|
||||
for i, card := range cards {
|
||||
balance := card.Find("id:AccountBalance")
|
||||
if balance == nil {
|
||||
t.Fatalf("card %d: id:AccountBalance did not resolve", i)
|
||||
}
|
||||
if balance.Text != want[i].balance {
|
||||
t.Errorf("card %d: balance text = %q, want %q", i, balance.Text, want[i].balance)
|
||||
}
|
||||
name := card.Find("id:AccountName")
|
||||
if name == nil {
|
||||
t.Fatalf("card %d: id:AccountName did not resolve", i)
|
||||
}
|
||||
if name.Text != want[i].name {
|
||||
t.Errorf("card %d: name text = %q, want %q", i, name.Text, want[i].name)
|
||||
}
|
||||
// The card's own merged label is not visible text; the web fallback in
|
||||
// the folio spec parses cardText and must not see a VoiceOver reading.
|
||||
if card.Element.Text != "" {
|
||||
t.Errorf("card %d: card text = %q, want empty", i, card.Element.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableAndClickableFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
elementType string
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"type":"Application","frame":{"x":0,"y":0,"width":402,"height":874},"enabled":true,"AXLabel":"Folio","AXValue":null,"AXUniqueId":null},
|
||||
{"type":"Other","frame":{"x":0,"y":62,"width":402,"height":778},"enabled":true,"AXLabel":null,"AXValue":null,"AXUniqueId":"HomeScreen"},
|
||||
{"type":"StaticText","frame":{"x":20,"y":76,"width":97,"height":24},"enabled":true,"AXLabel":"Accounts","AXValue":null,"AXUniqueId":null},
|
||||
{"type":"Button","frame":{"x":340,"y":71,"width":48,"height":48},"enabled":true,"AXLabel":"Log out","AXValue":null,"AXUniqueId":"LogoutButton"},
|
||||
{"type":"Button","frame":{"x":20,"y":130,"width":362,"height":72},"enabled":true,"AXLabel":"CH, Checking, $12.34, 0 transactions","AXValue":null,"AXUniqueId":"AccountCard"},
|
||||
{"type":"StaticText","frame":{"x":90,"y":150,"width":74,"height":18},"enabled":true,"AXLabel":"Checking","AXValue":null,"AXUniqueId":"AccountName"},
|
||||
{"type":"StaticText","frame":{"x":313,"y":157,"width":53,"height":18},"enabled":true,"AXLabel":"$12.34","AXValue":null,"AXUniqueId":"AccountBalance"},
|
||||
{"type":"Button","frame":{"x":20,"y":210,"width":362,"height":72},"enabled":true,"AXLabel":"SA, Savings, $500.00, 2 transactions","AXValue":null,"AXUniqueId":"AccountCard"},
|
||||
{"type":"StaticText","frame":{"x":90,"y":230,"width":66,"height":18},"enabled":true,"AXLabel":"Savings","AXValue":null,"AXUniqueId":"AccountName"},
|
||||
{"type":"StaticText","frame":{"x":306,"y":237,"width":60,"height":18},"enabled":true,"AXLabel":"$500.00","AXValue":null,"AXUniqueId":"AccountBalance"},
|
||||
{"type":"StaticText","frame":{"x":20,"y":716,"width":106,"height":14},"enabled":true,"AXLabel":"TOTAL BALANCE","AXValue":null,"AXUniqueId":null},
|
||||
{"type":"StaticText","frame":{"x":20,"y":731,"width":85,"height":33},"enabled":true,"AXLabel":"$512.34","AXValue":null,"AXUniqueId":null},
|
||||
{"type":"Button","frame":{"x":20,"y":777,"width":362,"height":48},"enabled":true,"AXLabel":"+ Add account","AXValue":null,"AXUniqueId":"AddAccountButton"},
|
||||
{"type":"StaticText","frame":{"x":141,"y":792,"width":121,"height":18},"enabled":true,"AXLabel":"+ Add account","AXValue":null,"AXUniqueId":null}
|
||||
]
|
||||
Reference in new issue
Block a user