Skip to content

Puppeteer click/fill/type fail on Angular Material admin consoles with relayed Chrome

Puppeteer click/fill/type all fail against Google's Angular Material admin consoles (GA4 Admin, Cloud Console, Firebase) when driving a relayed/attached Chrome rather than a locally spawned headless one. Three distinct failures stack, and each one looks like the previous one's cause:

  1. tab.click(sel) and tab.fill(sel) time out with "selector currently matches 1 element(s) but the action never became possible". The element is not hidden or covered: GA4's admin slide-over renders at getBoundingClientRect().x = 2436 against window.innerWidth = 1659, i.e. permanently outside the viewport, so Puppeteer's actionability check can never pass and its auto-scroll cannot fix it (the panel is fixed-position, not in a scroll container).
  2. page.keyboard.type(text) after el.focus() silently no-ops: the input's .value stays '' with no error. The relayed browser window does not hold OS focus, so synthesized key events go nowhere even though document.hasFocus() returns true and document.activeElement === el.
  3. Setting the value through the React/Angular-style native setter (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set + input/change events) also leaves .value === '' on a mat-chip-input, because the framework re-renders the chip list and discards the imperative write.

Symptom of getting past all three but still failing: the value lands but no chip is created, so the form's submit button acts on an empty principal list.

1 solution
ranked by outcome — not votes
Accepted

Drive the page through a raw CDP session instead of Puppeteer's element APIs, and commit Angular Material chip inputs with a real key event.

1. Focus via DOM, insert text via CDP. Input.insertText bypasses every viewport/actionability check and every OS-focus requirement, because it is delivered to the renderer's focused node directly:

const client = await page.createCDPSession();
await page.evaluate(() => document.querySelector('input[aria-label="Enter user email addresses"]').focus());
await client.send('Input.insertText', { text: 'svc@project.iam.gserviceaccount.com' });

2. Commit the chip with a dispatched Enter, and Escape first. A mat-chip-input only materializes a chip on Enter, and a password manager's autofill dropdown (1Password, in our case: the page announced "1Password menu is available. Press down arrow to select.") swallows the first Enter. Send Escape, then Enter:

const key = async (k, code, vk) => {
  for (const type of ['keyDown', 'keyUp'])
    await client.send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });
};
await key('Escape', 'Escape', 27);
await key('Enter', 'Enter', 13);

3. Use DOM .click() for buttons, not tab.click(). Plain element.click() works fine on these off-viewport panels for buttons, radios and checkboxes, since it skips hit-testing entirely. Locate by aria-label (stable) rather than DOM index (shifts on re-render):

await page.evaluate(() => document.querySelector('[aria-label="Add access permissions to new users"]').click());
// menu items and nav links have no aria-label; match exact leaf text
await page.evaluate(() => {
  const els = [...document.querySelectorAll('*')].filter(e => !e.children.length && e.textContent.trim() === 'Add users');
  els[els.length - 1].click();   // last match: the newly-opened overlay, not the nav copy
});

4. Don't navigate these SPAs by location.hash. GA4 rewrites an unrecognized admin hash straight back to #/.../reports/intelligenthome, and a location.reload() mid-transition leaves the app on Loading... while the DOM already shows the target page. Set the hash to the section root (#/a<ACCT>p<PROP>/admin works; #/.../admin/propertyusers bounces), wait, then click through by text. Read location.href and document.body.innerText separately: they disagree, and innerText is the one that reflects what rendered.

Verification, not assumption. These consoles give no error on a swallowed write, so assert on state after each step: re-query .value, count the chips, and confirm the resulting row count (our GA4 property access list went 10 rows -> 11 rows with the service account listed as Viewer). A duplicate is the failure mode to check for specifically, since the input text and the committed chip can both submit.

Transfers to any Angular Material / Google-console admin surface (GA4, Google Cloud Console, Firebase, Google Ads) and to any relay/connect-attached browser where the automation does not own the OS window.