Skip to content

Driving the Manager admin SPA headlessly

The admin panel is fully drivable and screenshottable headlessly. If you have been told otherwise, or you are looking at "Loading… Please Wait." and concluding the AngularJS SPA cannot be automated — read this page first. Verified live against admin.raklet.net on 2026-08-01, and exercised by 70 admin-regression browse tests on every PR.

The one-liner

pwsh scripts/dev/admin-spa-session.ps1 -Domain .raklet.net -Route '#/manager/contacts/'

That script does the whole correct sequence in a single invocation and leaves you on a rendered, authenticated admin page with a screenshot on disk. If it fails, it tells you which of the known causes it hit. Read the rest of this page only if you need to do something the script doesn't cover.

-Route takes either form:

Form Example Mechanism
URL (preferred) '#/manager/events/' $location.url() — ui-router picks the concrete state for you
State name 'manager.events.list' $state.go()

Prefer the URL form. Many manager states are abstract parents with no view of their own — $state.go('manager.events') throws Cannot transition to abstract state, while $location.url('/manager/events/') resolves to manager.events.list by itself. The script detects both the abstract case and the unregistered case and prints the valid alternatives rather than hanging.

Why it usually goes wrong

Five different conditions all render the same infinite "Loading… Please Wait." spinner. The SPA does not distinguish them (tracked as RAK-348), so people misdiagnose:

Cause How to confirm Fix
No server-side org context You never hit SwitchOrganisation Navigate https://login<domain>/SwitchOrganisation?permalink=<org> before the SPA route
Wrong route URL is bare /ng/ (hash route #/) Use /ng/#/manager/ — the bare route never renders the shell
Unregistered route The state isn't in $state.get() The module isn't deployed to that env, or isn't registered in app.module.js
Broken/stale bundle curl the bundle, look at line 1 A bare module.exports in a new SPA file kills bootstrap app-wide
Cold IIS JIT First hit after an app-pool recycle Warm over HTTP first; CI does this in scripts/ci/warm-admin-spa.ps1

Note that four of the five are environment/state problems, not code problems, and none of them is "headless can't run AngularJS".

Rules that actually matter

Do everything in ONE invocation. The browse daemon is shared and restarts between — and sometimes within — tool calls, dropping the authenticated tab to about:blank. In one measurement session, 3 of 4 runs restarted mid-flight. That looks exactly like "the tab crashed" and is the single most misleading failure mode. Per-session isolation is tracked as RAK-770 / ENG-97.

Acquire the lock first, or a parallel session will hijack your tab mid-capture:

scripts/dev/browse-lock.ps1 acquire -Wait      # capture with 6>&1 - it reports via Write-Host

Use the bare browse.exe daemon, not GStack Browser. The gstack sidebar extension interferes with Angular bootstrap on the admin shell. $B headless is plain Playwright Chromium and works.

Never use .scope() / .controller() / .isolateScope(). The deployed bundle runs with $compileProvider.debugInfoEnabled(false), so they return undefined on .raklet.net and prod while working fine locally — a trap that has broken a Test→Prod release gate. Walk the scope tree from the injector:

var injector = angular.element(document.body).injector();
var rootScope = injector.get('$rootScope');
// then walk $$childHead / $$nextSibling to find the scope owning your data

Never navigate with location.hash. Setting the hash changes the URL without driving a ui-router transition — verified: $state.current.name stayed on manager.dashboard and the DOM never changed. Use one of:

var inj = angular.element(document.body).injector();

// By URL - resolves abstract parents to the right concrete child. Preferred.
inj.get('$location').url('/manager/events/');

// By state name - only works on CONCRETE states.
inj.get('$state').go('manager.events.list');

inj.get('$rootScope').$applyAsync();   // NOT $apply(): it throws mid-digest,
                                       // which aborts the navigation silently

Then poll for the state to change rather than sleeping a fixed amount — a state that never changes is itself the diagnostic.

Prefer injector service calls over UI flows for setup/teardown. When a list view is behind a lagging search index, call the service directly rather than polling the UI — this cut one suite from ~33 min to ~60s:

var svc = angular.element(document.body).injector().get('EventsService');
window.__r = null;
svc.cloneEvent(id).then(function (r) { window.__r = JSON.stringify(r); });

Then poll window.__r from PowerShell — browse js does not await promises.

Verifying you actually got there

Don't trust a screenshot alone; assert the SPA bootstrapped:

(function () {
  var inj = angular.element(document.body).injector();
  return JSON.stringify({
    hasInjector: !!inj,
    state: inj ? inj.get('$state').current.name : null,
    navPresent: !!document.querySelector('.Manager-navigation, .ManagerContent, #ManagerNavigationDiv')
  });
})()

A healthy authenticated manager page looks like:

{ "hasInjector": true, "state": "manager.dashboard", "navPresent": true }

ReferenceError: angular is not defined means you are on about:blank or bounced to the login host — the daemon lost your session. Re-run the whole flow in one go.

Large JS payloads

browse js <arg> has a ~32KB argument limit and will fail with "filename too long". Write the expression to a file and use browse eval <file> instead.

See also