Files
StarPilot/openpilot/starpilot/galaxy/tests/test_bluetooth_stability.mjs
T
Danny e11ce3071d Galaxy: Add companion, Spotify and phone-aware web controls
Add companion installation and updates, Spotify setup and playback, destination
sharing, and Android Auto controls. Adapt Galaxy pages to phone transports and
device type, improve Bluetooth pairing feedback, and cover the new web flows.
2026-10-22 15:37:20 -07:00

126 lines
7.3 KiB
JavaScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { BluetoothFeed, BluetoothPage } from '../web/js/bluetooth.js';
import { compile } from '../web/vendor/vue/vue.esm-browser.js';
const car = {address: 'AA:BB:CC:DD:EE:FF', name: 'Car', paired: true, connected: false, trusted: true};
const adapter = {...car, address: '11:22:33:44:55:66', name: 'Adapter', paired: false, trusted: false};
const status = (devices = [car], extra = {}) => ({version: 1, available: true, parked: true,
powered: true, discovering: true, errorCode: null, pairing: null, devices, ...extra});
const response = (body, code = 200) => ({status: code, ok: code === 200, json: async () => body});
const deferred = () => {let resolve; const promise = new Promise((done) => {resolve = done}); return {promise, resolve};};
function harness(fetcher, unauthorized = () => {}) {
const states = [], timers = new Map(); let next = 0;
const feed = new BluetoothFeed({fetcher, unauthorized, publish: (value) => states.push(value),
later: (fn, ms) => {timers.set(++next, {fn, ms}); return next}, cancelTimer: (id) => timers.delete(id)});
return {feed, states, timers};
}
test('first-load connection errors render without accessing missing adapter state', () => {
const render = compile(BluetoothPage.template, {decodeEntities: value => value});
const vm = {...BluetoothPage.data(), mode:'local', error:'Connection interrupted', unauthorized() {}};
for (const [key, getter] of Object.entries(BluetoothPage.computed)) {
Object.defineProperty(vm, key, {get: () => getter.call(vm)});
}
for (const [key, method] of Object.entries(BluetoothPage.methods)) vm[key] = method.bind(vm);
assert.doesNotThrow(() => render(vm, []));
});
test('background reads retain existing state and never mark buttons busy', async () => {
let devices = [car, adapter, {...car, address: '22:22:22:22:22:22', name: ' '},
{...car, address: '33:33:33:33:33:33', name: '33:33:33:33:33:33'}];
const delayed = deferred(); let wait = false;
const {feed, states} = harness(async () => wait ? delayed.promise : response(status(devices)));
await feed.start(); const before = feed.status; wait = true;
const poll = feed.refresh();
assert.equal(feed.status, before); assert.equal(feed.busy, false);
devices = [{...adapter, name: ' Adapter renamed '}, {...car, connected: true}];
delayed.resolve(response(status(devices))); await poll;
assert.deepEqual(feed.status.devices.map((d) => d.name), ['Car', 'Adapter renamed']);
assert.equal(feed.status.devices[0].connected, true);
assert(states.every((s) => s.busy === false)); feed.stop();
});
test('action supersedes delayed poll JSON, stays busy and blocks duplicates', async () => {
const delayed = deferred(), post = deferred(); let wait = false, signal; let posts = 0;
const {feed, timers} = harness(async (url, options) => {
if (options.method === 'POST') {posts++; return post.promise;}
if (wait) {wait = false; signal = options.signal; return {...response(null), json: () => delayed.promise};}
return response(status([car], {discovering: false}));
});
await feed.start(); wait = true; const poll = feed.refresh();
while (!signal) await Promise.resolve();
const action = feed.action('scan');
assert.equal(feed.busy, true); assert.equal(signal.aborted, true);
await feed.action('scan'); assert.equal(posts, 1);
delayed.resolve(status([adapter], {parked: false})); await poll;
assert.equal(feed.busy, true); assert.equal(feed.status.parked, true);
assert.equal(feed.status.devices[0].address, car.address);
post.resolve(response(status([car], {discovering: true}))); await action;
assert.equal(feed.busy, false);
assert.equal([...timers.values()].filter((t) => t.ms === 2000).length, 1); feed.stop();
});
test('failed actions and hard timeouts re-arm polling without accepting stale responses', async () => {
const delayed = deferred(); let mode = 'ready'; let posts = 0;
const {feed, timers} = harness(async (url, options) => {
if (options.method === 'POST') {
posts++;
if (mode === 'fail') throw new Error('Connection interrupted');
return delayed.promise;
}
return response(status());
});
await feed.start(); mode = 'fail'; await feed.action('scan');
assert.equal(feed.busy, false); assert.match(feed.error, /interrupted/);
assert.equal([...timers.values()].filter((t) => t.ms === 2000).length, 1);
mode = 'timeout'; const action = feed.action('connect', {address: car.address});
assert.equal(feed.busy, true);
[...timers.values()].find((t) => t.ms === 30000).fn();
assert.equal(feed.busy, false); assert.match(feed.error, /too long/);
const poll = [...timers.values()].find((t) => t.ms === 2000); assert(poll);
await poll.fn(); assert.equal(feed.error, '');
delayed.resolve(response(status([adapter], {powered: false}))); await action;
assert.equal(feed.status.powered, true); assert.equal(feed.status.devices[0].address, car.address);
assert.equal(posts, 2); feed.stop();
});
test('authorization failures and parked gates prevent backend actions', async () => {
let code = 200, revoked = 0; const calls = [];
const {feed, timers} = harness(async (url) => {calls.push(url); return response(status([], {parked: false}), code)}, () => revoked++);
await feed.start(); await feed.action('power', {enabled: false});
assert.equal(calls.length, 1);
code = 401; await feed.refresh();
assert.equal(revoked, 1); assert.equal(feed.active, false); assert.equal(timers.size, 0);
});
test('stopping cancels pairing and resets device ordering for the next session', async () => {
const prompt = {id: 'a'.repeat(32), kind: 'confirmation', value: '123456', displayOnly: false};
let devices = [car, adapter]; const calls = [];
const {feed} = harness(async (url, options) => {calls.push(options); return response(status(devices,
{pairing: {address: car.address, state: 'pairing', prompt}}))});
await feed.start(); feed.stop(true);
assert(calls.some((options) => options.keepalive && JSON.parse(options.body).operation === 'cancel_pair'));
devices = [adapter, car]; await feed.start();
assert.deepEqual(feed.status.devices.map((d) => d.address), [adapter.address, car.address]); feed.stop();
});
test('timed-out background polling preserves status and resumes even when fetch ignores abort', async () => {
const delayed = deferred(); let wait = false;
const {feed, timers, states} = harness(async () => wait ? delayed.promise : response(status()));
await feed.start(); const before = feed.status; wait = true; const pending = feed.refresh();
[...timers.values()].find((t) => t.ms === 12000).fn();
assert.equal(feed.status, before); assert.equal(feed.busy, false);
wait = false; await [...timers.values()].find((t) => t.ms === 2000).fn();
delayed.resolve(response(status([adapter]))); await pending;
assert.equal(feed.status.devices[0].address, car.address);
assert(states.every((s) => s.busy === false)); feed.stop();
});
test('hiding the page keeps a phone pairing window open so the phone can find the comma', async () => {
const calls = [];
const {feed} = harness(async (url, options) => {calls.push(options); return response(status([car],
{discovering: false, pairing: {address: '', state: 'pairing', prompt: null, incoming: true, name: ''}}))});
await feed.start(); feed.stop(true);
assert(!calls.some((options) => options?.body && JSON.parse(options.body).operation === 'cancel_pair'));
});