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¶
scripts/dev/admin-spa-session.ps1— the scripted version of this pagebrowse-tests/helpers/login.ps1— the login flow the suites userelease-browse-gate-triage.md— triaging a red release browse gatebrowse-test-failures-status.md— current quarantine list