|
| 1 | +/** |
| 2 | + * #1800: a positional list shaped like a selector (a recognized key, or an |
| 3 | + * unrecognized `key=value`) must never silently degrade to a `text` wait — |
| 4 | + * that degradation is exactly the shape that reads as "element absent" after |
| 5 | + * a full timeout instead of the caller's own argument mistake. See #1035 for |
| 6 | + * the sibling fix on click/press/fill/get. |
| 7 | + */ |
| 8 | +import assert from 'node:assert/strict'; |
| 9 | +import fc from 'fast-check'; |
| 10 | +import { test } from 'vitest'; |
| 11 | +import { isValidSelectorExpression, SELECTOR_KEY_NAMES } from '@agent-device/selectors'; |
| 12 | +import { PROPERTY_RUNS } from '../__tests__/test-utils/index.ts'; |
| 13 | +import { parseWaitPositionals, resolveWaitBudgetMs } from './wait-positionals.ts'; |
| 14 | + |
| 15 | +function assertInvalid(args: string[], messageFragment: string) { |
| 16 | + const result = parseWaitPositionals(args); |
| 17 | + assert.ok(result, `expected a result for ${JSON.stringify(args)}`); |
| 18 | + assert.equal(result.kind, 'invalid'); |
| 19 | + if (result.kind !== 'invalid') return; |
| 20 | + assert.ok( |
| 21 | + result.message.includes(messageFragment), |
| 22 | + `expected message to include ${JSON.stringify(messageFragment)}, got ${JSON.stringify(result.message)}`, |
| 23 | + ); |
| 24 | +} |
| 25 | + |
| 26 | +// --- the issue's reproduction shapes ----------------------------------- |
| 27 | + |
| 28 | +test('a condition word followed by a selector-shaped value is rejected, not read as text (#1800 repro)', () => { |
| 29 | + const result = parseWaitPositionals(['open', 'label="Open"', '25000']); |
| 30 | + assert.ok(result); |
| 31 | + assert.notEqual(result.kind, 'text'); |
| 32 | + assertInvalid(['open', 'label="Open"', '25000'], 'label="Open"'); |
| 33 | +}); |
| 34 | + |
| 35 | +test('"exists" followed by a selector-shaped value is rejected and points at the selector form', () => { |
| 36 | + assertInvalid(['exists', 'label="x"', '100'], "wait 'visible"); |
| 37 | +}); |
| 38 | + |
| 39 | +test('unknown selector key is rejected with the supported-key list, not read as text', () => { |
| 40 | + const result = parseWaitPositionals(['lbl="x"', '100']); |
| 41 | + assert.ok(result); |
| 42 | + assert.equal(result.kind, 'invalid'); |
| 43 | + if (result.kind !== 'invalid') return; |
| 44 | + assert.equal(result.reason, 'unknown-selector-key'); |
| 45 | + assert.ok(result.message.includes('lbl')); |
| 46 | + assert.ok(result.message.includes('label=')); |
| 47 | +}); |
| 48 | + |
| 49 | +// --- still-valid selector forms are untouched --------------------------- |
| 50 | + |
| 51 | +test('a valid selector still parses as selector', () => { |
| 52 | + const result = parseWaitPositionals(['label="Open"', '5000']); |
| 53 | + assert.deepEqual(result, { |
| 54 | + kind: 'selector', |
| 55 | + selectorExpression: 'label="Open"', |
| 56 | + timeoutMs: 5000, |
| 57 | + }); |
| 58 | +}); |
| 59 | + |
| 60 | +test('"visible" leading a valid selector still parses as selector (boolean key, not a condition word)', () => { |
| 61 | + const result = parseWaitPositionals(['visible', 'label="Open"', '25000']); |
| 62 | + assert.deepEqual(result, { |
| 63 | + kind: 'selector', |
| 64 | + selectorExpression: 'visible label="Open"', |
| 65 | + timeoutMs: 25000, |
| 66 | + }); |
| 67 | +}); |
| 68 | + |
| 69 | +// --- trailing tokens after a valid selector prefix are rejected too ----- |
| 70 | + |
| 71 | +test('a valid selector prefix followed by an unquoted extra word is rejected, not merged into text', () => { |
| 72 | + assertInvalid(['label="Open"', 'foo', '5000'], 'extra arguments'); |
| 73 | +}); |
| 74 | + |
| 75 | +// --- bare text keeps working --------------------------------------------- |
| 76 | + |
| 77 | +test('bare single-word text still parses as text', () => { |
| 78 | + const result = parseWaitPositionals(['Continue', '1500']); |
| 79 | + assert.deepEqual(result, { kind: 'text', text: 'Continue', timeoutMs: 1500 }); |
| 80 | +}); |
| 81 | + |
| 82 | +test('bare multi-word text still parses as text', () => { |
| 83 | + const result = parseWaitPositionals(['Sign', 'in', '2000']); |
| 84 | + assert.deepEqual(result, { kind: 'text', text: 'Sign in', timeoutMs: 2000 }); |
| 85 | +}); |
| 86 | + |
| 87 | +test('explicit "text" keyword form bypasses selector-shape rejection entirely', () => { |
| 88 | + // Even though "label=Open" is selector-shaped, the explicit `text` keyword is the caller's |
| 89 | + // unambiguous escape hatch and must always win. |
| 90 | + const result = parseWaitPositionals(['text', 'label=Open', '5000']); |
| 91 | + assert.deepEqual(result, { kind: 'text', text: 'label=Open', timeoutMs: 5000 }); |
| 92 | +}); |
| 93 | + |
| 94 | +test('free prose containing a bare "=" with no key stays literal text', () => { |
| 95 | + const result = parseWaitPositionals(['Total', '=', '5', '3000']); |
| 96 | + assert.deepEqual(result, { kind: 'text', text: 'Total = 5', timeoutMs: 3000 }); |
| 97 | +}); |
| 98 | + |
| 99 | +// --- documented boundary: a bare selector-key word alone is rejected, not text --- |
| 100 | + |
| 101 | +test('a single word that IS a recognized selector key is rejected rather than read as literal text', () => { |
| 102 | + // "id" alone cannot become a valid selector (it needs a value), but it also must not silently |
| 103 | + // become literal wait text — an agent that meant the word "id" has to say so via `wait text`. |
| 104 | + assertInvalid(['id', '3000'], "wait text 'id'"); |
| 105 | +}); |
| 106 | + |
| 107 | +// --- resolveWaitBudgetMs stays sane for the new variant ------------------- |
| 108 | + |
| 109 | +test('resolveWaitBudgetMs returns null for a rejected selector-shaped positional list', () => { |
| 110 | + assert.equal(resolveWaitBudgetMs(['open', 'label="Open"', '25000']), null); |
| 111 | +}); |
| 112 | + |
| 113 | +// --- properties over examples --------------------------------------------- |
| 114 | + |
| 115 | +const TEXT_SELECTOR_KEYS = SELECTOR_KEY_NAMES.filter((key) => !isValidSelectorExpression(key)); |
| 116 | +const BOOLEAN_SELECTOR_KEYS = SELECTOR_KEY_NAMES.filter((key) => isValidSelectorExpression(key)); |
| 117 | + |
| 118 | +const selectorValueArb = fc.oneof( |
| 119 | + fc.constantFrom('Open', 'Sign in', "it's", 'a || b', 'key=value'), |
| 120 | + fc.string({ minLength: 1, maxLength: 8 }).filter((value) => value.trim().length > 0), |
| 121 | +); |
| 122 | + |
| 123 | +/** One token of a generated, guaranteed-valid selector expression. */ |
| 124 | +const selectorTokenArb: fc.Arbitrary<string> = fc.oneof( |
| 125 | + fc |
| 126 | + .record({ key: fc.constantFrom(...TEXT_SELECTOR_KEYS), value: selectorValueArb }) |
| 127 | + .map(({ key, value }) => `${key}=${JSON.stringify(value)}`), |
| 128 | + fc.constantFrom(...BOOLEAN_SELECTOR_KEYS), |
| 129 | +); |
| 130 | + |
| 131 | +const validSelectorTokensArb: fc.Arbitrary<string[]> = fc.array(selectorTokenArb, { |
| 132 | + minLength: 1, |
| 133 | + maxLength: 3, |
| 134 | +}); |
| 135 | + |
| 136 | +test('property: a generated valid selector expression never parses as text or invalid', () => { |
| 137 | + fc.assert( |
| 138 | + fc.property(validSelectorTokensArb, (tokens) => { |
| 139 | + const result = parseWaitPositionals(tokens); |
| 140 | + assert.ok(result); |
| 141 | + assert.equal(result.kind, 'selector'); |
| 142 | + }), |
| 143 | + { numRuns: PROPERTY_RUNS }, |
| 144 | + ); |
| 145 | +}); |
| 146 | + |
| 147 | +const plainWordArb = fc |
| 148 | + .string({ minLength: 1, maxLength: 6 }) |
| 149 | + .filter( |
| 150 | + (token) => |
| 151 | + token.trim().length > 0 && |
| 152 | + !token.includes('=') && |
| 153 | + !token.startsWith('@') && |
| 154 | + token !== 'text' && |
| 155 | + token !== 'stable' && |
| 156 | + Number.isNaN(Number(token)), |
| 157 | + ); |
| 158 | + |
| 159 | +const recognizedKeyValueTokenArb: fc.Arbitrary<string> = fc |
| 160 | + .record({ key: fc.constantFrom(...SELECTOR_KEY_NAMES), value: selectorValueArb }) |
| 161 | + .map(({ key, value }) => `${key}=${JSON.stringify(value)}`); |
| 162 | + |
| 163 | +const unrecognizedKeyValueTokenArb: fc.Arbitrary<string> = fc |
| 164 | + .record({ |
| 165 | + key: fc |
| 166 | + .string({ minLength: 1, maxLength: 8 }) |
| 167 | + .filter( |
| 168 | + (key) => |
| 169 | + /^[a-zA-Z][a-zA-Z0-9]*$/.test(key) && |
| 170 | + !SELECTOR_KEY_NAMES.includes(key.toLowerCase() as (typeof SELECTOR_KEY_NAMES)[number]), |
| 171 | + ), |
| 172 | + value: selectorValueArb, |
| 173 | + }) |
| 174 | + .map(({ key, value }) => `${key}=${JSON.stringify(value)}`); |
| 175 | + |
| 176 | +const keyValueShapedTokenArb = fc.oneof(recognizedKeyValueTokenArb, unrecognizedKeyValueTokenArb); |
| 177 | + |
| 178 | +test('property: any positional list containing a key=value-shaped token never parses as text', () => { |
| 179 | + fc.assert( |
| 180 | + fc.property( |
| 181 | + fc.array(plainWordArb, { maxLength: 3 }), |
| 182 | + keyValueShapedTokenArb, |
| 183 | + fc.nat({ max: 3 }), |
| 184 | + (plainTokens, kvToken, insertAtRaw) => { |
| 185 | + const insertAt = Math.min(insertAtRaw, plainTokens.length); |
| 186 | + const tokens = [...plainTokens.slice(0, insertAt), kvToken, ...plainTokens.slice(insertAt)]; |
| 187 | + const result = parseWaitPositionals(tokens); |
| 188 | + assert.ok(result); |
| 189 | + assert.notEqual(result.kind, 'text'); |
| 190 | + }, |
| 191 | + ), |
| 192 | + { numRuns: PROPERTY_RUNS }, |
| 193 | + ); |
| 194 | +}); |
0 commit comments