Seven traps that stand between you and a scripted end-to-end real call (operator + customer, real media) against a vuer_oss / vuer_css stack. All of them were found the hard way on 2026-09-07 while building the harness that produced the measurement in janus-memory-leak-rca Part 8.

For Agents

Every one of these fails silently or misleadingly — a skeleton page, an unexplained reload, a zero-length querySelectorAll, a compatibility check that answers unknown. None of them produce a useful error message. If a vuer browser automation “almost works”, it is almost certainly one of these seven.

1. These apps cannot be driven over plain HTTP — and curl will not tell you

Content-Security-Policy: upgrade-insecure-requests plus Secure cookies mean a real browser cannot use an http:// origin:

  • The browser rewrites every subresource to https://, hits the non-TLS nginx, and every stylesheet and script dies with ERR_SSL_PROTOCOL_ERROR.
  • What you get back is a bare skeleton: the HTML arrives, so it looks served, but nothing is wired — no form handlers, no sockets.
  • _csrf and vuersid are Secure, so login could never have worked over http anyway.

curl ignores CSP, so curl-over-http ALWAYS looks fine

This is the genuine trap. curl http://host/… returns 200 with sensible-looking HTML and you conclude the stack is healthy. It is not a valid smoke test for anything browser-driven. Put a TLS proxy in front of the stack before you point a browser at it.

2. The post-login redirect is built from config, not from the request

WebServerAuth.js:821:

https://${config.get('hosts.oss')}${redirectTo}

⇒ if you run on a non-standard port, the port must be in hosts.oss. Setting the Host header, or merely reaching the box on :20443, is not enough — the redirect will send the browser to an origin that does not exist.

3. There is a server-side browser gate: GET /api/pre-check

The User-Agent decides whether the app will run at all:

User-AgentVerdict returned
HeadlessChrome/…unknown
Chrome/120not_compatible
Chrome/141compatible

⇒ Playwright must use full chromium (channel: 'chromium', not the headless shell) and a realistic, current UA string. Both, not either.

4. The submit handler is attached only once /api/pre-check resolves

After goto, clicking submit too early falls through to a native GET form submit: the URL becomes ?_csrf=…&lastName=… and the page simply reloads. It reads as “the form didn’t do anything”.

wait for /api/pre-check to resolve before interacting with the form.

5. Operator and customer need SEPARATE browser contexts

Cookies are not port-scoped. Same hostname on two ports = one cookie jar, so a single context leaks the operator session onto the customer origin and the customer page comes up authenticated as the operator.

⇒ one browser.newContext() per participant, always.

6. [data-action="take-call"] lives inside a <template>

So querySelectorAll('[data-action="take-call"]').length === 0 means no customer row was rendered — it does not mean “the button is hidden”. Debug the waiting list, not the button.

7. The registration form’s firstName is letters-only

Pattern ^[a-zA-Z…]{2,25}$. Fixtures containing digits, hyphens or spaces fail client-side validation.

Bonus — the operator sees the customer but has no accept button

Not an environment problem: a role problem, and a real defect. Documented in janus-memory-leak-rca Part 9.

videoChat.receiveCall is granted only to the operator role in config/roles.json, but WebServerAuth.js:934 sets req.session.role = req.user.getMainRole(), and getMainRole() (server/db/model/user.js:187) returns the first entry of acl.roleList present in the user’s rights ⇒ admin for a multi-role user.

Fix: POST /api/role-switch with document.body.dataset.csrftoken — which is exactly what default.layout.js:48 does.