mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 23:43:44 +08:00
423 lines
24 KiB
JavaScript
423 lines
24 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import { readFileSync } from "node:fs"
|
|
import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage,
|
|
expiryInfo, packagePreflight, importProblem, installChecks } from "../web/js/android-auto.js"
|
|
|
|
const setup = { enabled: true, bluetoothEnabled: true, parked: true, installReady: true, serviceReady: true,
|
|
identity: { installed: true, message: "Package ready" },
|
|
import: { state: "idle" }, maxUploadBytes: 200 * 1024 * 1024 }
|
|
const prompt = { id: "a".repeat(32), kind: "confirmation", value: "123456", displayOnly: false }
|
|
const pairing = { active: true, receiver: { address: "AA:BB:CC:DD:EE:FF", name: "Car" }, prompt, approved: false }
|
|
|
|
assert.equal(validPairingStatus(pairing), true)
|
|
assert.equal(validPairingStatus({ ...pairing, receiver: { ...pairing.receiver, address: "bad" } }), false)
|
|
assert.equal(validPairingStatus({ ...pairing, prompt: { ...prompt, id: "bad" } }), false)
|
|
assert.equal(validPromptInput({ kind: "pin" }, "1234"), true)
|
|
assert.equal(validPromptInput({ kind: "pin" }, "\n"), false)
|
|
assert.equal(validPromptInput({ kind: "passkey" }, "1234567"), false)
|
|
assert.equal(validPromptInput(prompt, ""), true)
|
|
assert.match(AndroidAutoPage.template, /Check that this code matches your car/)
|
|
assert.match(AndroidAutoPage.template, /Cancel Search \/ Pairing/)
|
|
assert.match(AndroidAutoPage.template, /Wired USB setup is unavailable/)
|
|
assert.doesNotMatch(AndroidAutoPage.template, /v-html|autoaccept/i)
|
|
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
|
|
assert.match(app, /AndroidAutoPage v-else-if="route\.path === '\/android-auto'"/)
|
|
|
|
const replies = {
|
|
"./api/android-auto/setup": setup,
|
|
"./api/android-auto/pairing/status": { pairing, selectedReceiver: null },
|
|
"./api/android-auto/receivers": { receivers: [{ address: "AA:BB:CC:DD:EE:FF", name: "Saved car" }] },
|
|
}
|
|
const calls = [], states = [], timers = new Map()
|
|
let timerId = 0
|
|
const response = (body, code = 200) => ({ status: code, ok: code >= 200 && code < 300, json: async () => structuredClone(body) })
|
|
const feed = new AndroidAutoFeed({ publish: (value) => states.push(value),
|
|
uploader: async (path, options, progress) => { calls.push([path, options]); progress({ loaded: 16, total: 32 }); return response({ ok: true }) },
|
|
fetcher: async (path, options) => {
|
|
calls.push([path, options])
|
|
return response(replies[path] || { ok: true })
|
|
}, later: (fn, ms) => { timers.set(++timerId, { fn, ms }); return timerId }, cancelTimer: (id) => timers.delete(id) })
|
|
await feed.start()
|
|
assert.equal(feed.setup.identity.installed, true)
|
|
assert.equal(feed.pairing.prompt.value, "123456")
|
|
assert.deepEqual(calls.slice(0, 2).map(([path]) => path), ["./api/android-auto/setup", "./api/android-auto/pairing/status"])
|
|
assert(calls.every(([, options]) => options.credentials === "same-origin"))
|
|
assert([...timers.values()].some((timer) => timer.ms === 1000))
|
|
await feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted: true, value: "" })
|
|
assert.deepEqual(JSON.parse(calls.find(([path]) => path.endsWith("/response"))[1].body),
|
|
{ prompt_id: prompt.id, accepted: true, value: "" })
|
|
assert.equal(await feed.upload({ size: setup.maxUploadBytes + 1 }), false)
|
|
assert.match(feed.error, /size limit/)
|
|
const packageFile = { size: 32 }
|
|
assert.equal(await feed.upload(packageFile), true)
|
|
assert.equal(calls.find(([path]) => path.endsWith("/upload"))[1].body, packageFile)
|
|
assert.equal(await feed.removePackage(), true)
|
|
assert.equal(calls.find(([path]) => path.endsWith("/identity"))[1].method, "DELETE")
|
|
assert.match(AndroidAutoPage.template, /Delete Package/)
|
|
feed.stop(true)
|
|
assert(calls.some(([path, options]) => path.endsWith("/cancel") && options.keepalive))
|
|
await feed.start()
|
|
replies["./api/android-auto/pairing/status"] = {
|
|
pairing: { active: false, receiver: null, prompt: null, approved: false }, selectedReceiver: null,
|
|
runtime: { state: "idle", running: false, auto_connect: false },
|
|
}
|
|
await feed.refresh()
|
|
assert.equal(await feed.loadReceivers(), true)
|
|
assert.deepEqual(feed.receivers, [{ address: "AA:BB:CC:DD:EE:FF", name: "Saved car" }])
|
|
assert.equal(await feed.control("select_receiver", { address: "AA:BB:CC:DD:EE:FF" }), true)
|
|
assert.deepEqual(JSON.parse(calls.find(([path]) => path.endsWith("/control"))[1].body),
|
|
{ action: "select_receiver", address: "AA:BB:CC:DD:EE:FF" })
|
|
assert.match(AndroidAutoPage.template, /Connect to car/)
|
|
assert.match(AndroidAutoPage.template, />Disconnect</)
|
|
assert.match(AndroidAutoPage.template, /Automatic Connection/)
|
|
assert.equal(await feed.setEnabled(false), true)
|
|
assert.deepEqual(JSON.parse(calls.find(([path]) => path.endsWith("/enable"))[1].body), { enabled: false })
|
|
assert.equal(await feed.setEnabled(true, true), true)
|
|
assert.deepEqual(JSON.parse(calls.filter(([path]) => path.endsWith("/enable")).at(-1)[1].body),
|
|
{ enabled: true, enableBluetooth: true })
|
|
assert.equal(await feed.setEnabled(false, true), false, "Bluetooth consent cannot be used to disable Android Auto")
|
|
feed.stop()
|
|
replies["./api/android-auto/pairing/status"] = { pairing: { active: false, receiver: null, prompt: null, approved: false },
|
|
selectedReceiver: null }
|
|
await feed.start()
|
|
assert.equal(feed.pairing.active, false, "reconnect must discard the old prompt")
|
|
replies["./api/android-auto/pairing/status"].selectedReceiver = { address: "AA:BB:CC:DD:EE:FF", name: "My car" }
|
|
await feed.refresh()
|
|
assert.equal(feed.selected.name, "My car")
|
|
feed.stop()
|
|
|
|
const expiredReplies = { pairing, selectedReceiver: null }
|
|
const expired = new AndroidAutoFeed({ publish: () => {}, fetcher: async (path) => response(path.endsWith("/setup") ? setup : expiredReplies),
|
|
later: () => 1, cancelTimer: () => {} })
|
|
await expired.start()
|
|
expiredReplies.pairing = { active: false, receiver: null, prompt: null, approved: false }
|
|
await expired.refresh()
|
|
assert.equal(expired.endReason, "ended")
|
|
expired.stop()
|
|
|
|
let release
|
|
const stale = new AndroidAutoFeed({ publish: () => {}, fetcher: () => new Promise((resolve) => { release = resolve }),
|
|
later: () => 1, cancelTimer: () => {} })
|
|
const loading = stale.start()
|
|
stale.stop()
|
|
release(response(setup))
|
|
await loading
|
|
assert.equal(stale.setup, null)
|
|
|
|
let revoked = 0
|
|
const unauthorized = new AndroidAutoFeed({ publish: () => {}, unauthorized: () => revoked++,
|
|
fetcher: async () => response({ error: "Sign in" }, 401), later: () => 1, cancelTimer: () => {} })
|
|
await unauthorized.start()
|
|
assert.equal(revoked, 1)
|
|
assert.equal(unauthorized.active, false)
|
|
|
|
const timeoutTimers = new Map()
|
|
let timeoutId = 0
|
|
const timedOut = new AndroidAutoFeed({ publish: () => {}, fetcher: (_path, options) => new Promise((_resolve, reject) => {
|
|
options.signal.addEventListener("abort", () => reject(new Error("aborted")))
|
|
}), later: (fn, ms) => { timeoutTimers.set(++timeoutId, { fn, ms }); return timeoutId },
|
|
cancelTimer: (id) => timeoutTimers.delete(id) })
|
|
const waitForTimeout = timedOut.start()
|
|
const timeout = [...timeoutTimers.values()][0]
|
|
assert.equal(timeout.ms, 8000)
|
|
timeout.fn()
|
|
await waitForTimeout
|
|
assert.match(timedOut.error, /too long/)
|
|
timedOut.stop()
|
|
|
|
const parked = new AndroidAutoFeed({ publish: () => {}, fetcher: async (path) => response(path.endsWith("/setup") ?
|
|
{ ...setup, parked: false } : { pairing: { active: false, receiver: null, prompt: null, approved: false },
|
|
selectedReceiver: null }),
|
|
later: () => 1, cancelTimer: () => {} })
|
|
await parked.start()
|
|
assert.equal(await parked.action("./api/android-auto/pairing"), false)
|
|
parked.uploader = async (_path, options) => {
|
|
assert.equal(options.body, packageFile)
|
|
return response({ ok: true })
|
|
}
|
|
for (const enabled of [true, false]) {
|
|
parked.setup.enabled = enabled
|
|
assert.equal(await parked.upload(packageFile), true, "uploads work onroad with Android Auto on or off")
|
|
parked.setup.enabled = enabled
|
|
parked.runtime = { running: true }
|
|
assert.equal(await parked.removePackage(), true, "deletion works onroad with Android Auto on, off, or connected")
|
|
}
|
|
parked.setup.import.state = "running"
|
|
assert.equal(await parked.upload(packageFile), false, "overlapping imports remain blocked")
|
|
assert.equal(await parked.removePackage(), false, "deletion does not race an active import")
|
|
assert.equal(await parked.setEnabled(true), true, "enabling works onroad")
|
|
parked.setup.installReady = false
|
|
assert.equal(await parked.setEnabled(true), false, "enabling still requires the display and encoder")
|
|
assert.equal(await parked.setEnabled(false), true, "disabling works even when hardware is unavailable")
|
|
parked.stop()
|
|
assert.equal(await parked.upload(packageFile), false, "inactive sessions cannot upload")
|
|
assert.equal(await parked.setEnabled(true), false, "inactive sessions cannot enable Android Auto")
|
|
|
|
function page(selectedSetup = setup) {
|
|
const vm = { ...AndroidAutoPage.data(), setup: structuredClone(selectedSetup) }
|
|
for (const [key, getter] of Object.entries(AndroidAutoPage.computed)) Object.defineProperty(vm, key, { get: () => getter.call(vm) })
|
|
for (const [key, method] of Object.entries(AndroidAutoPage.methods)) vm[key] = method.bind(vm)
|
|
return vm
|
|
}
|
|
const uploadPage = page({ ...setup, enabled: false })
|
|
const uploadCalls = []
|
|
uploadPage.feed = {
|
|
async setEnabled(value) { uploadCalls.push(["enable", value]); uploadPage.setup.enabled = value; return true },
|
|
async upload(file) { uploadCalls.push(["upload", file]); return true },
|
|
}
|
|
for (const [name, type] of [["android-auto.apk", ""], ["android-auto.xapk", "application/zip"], ["android-auto.apkm", "application/octet-stream"]]) {
|
|
const file = { name, size: 5 * 1024 * 1024, type, slice: () => new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]) }
|
|
uploadPage.setup.enabled = false
|
|
await uploadPage.choosePackage({ target: { files: [file] } })
|
|
assert.equal(uploadPage.packageFile, file)
|
|
assert.equal(uploadPage.uploadReason, "", "valid selection can enable and upload")
|
|
assert.equal(await uploadPage.upload(), true)
|
|
assert.deepEqual(uploadCalls.slice(-2), [["enable", true], ["upload", file]])
|
|
}
|
|
uploadPage.choosePackage({ target: { files: [{ name: "empty.apk", size: 0 }] } })
|
|
assert.match(uploadPage.uploadReason, /empty/)
|
|
assert.equal(await uploadPage.upload(), false)
|
|
uploadPage.choosePackage({ target: { files: [{ name: "large.apk", size: setup.maxUploadBytes + 1 }] } })
|
|
assert.match(uploadPage.uploadReason, /maximum size/)
|
|
await uploadPage.choosePackage({ target: { files: [{ name: "ok.apk", size: 5 * 1024 * 1024, slice: () => new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]) }] } })
|
|
uploadPage.setup.parked = false
|
|
assert.equal(uploadPage.uploadReason, "")
|
|
for (const enabled of [true, false]) {
|
|
uploadPage.setup.enabled = enabled
|
|
const beforeUpload = uploadCalls.length
|
|
assert.equal(await uploadPage.upload(), true)
|
|
const expected = enabled ? [["upload", uploadPage.packageFile]] : [["enable", true], ["upload", uploadPage.packageFile]]
|
|
assert.deepEqual(uploadCalls.slice(beforeUpload), expected, "onroad installation can enable Android Auto before uploading")
|
|
}
|
|
uploadPage.setup.parked = true
|
|
uploadPage.busy = true
|
|
assert.equal(uploadPage.uploadReason, "", "a request blocks interaction without changing upload eligibility")
|
|
assert.equal(await uploadPage.upload(), false, "the action guard still rejects uploads while busy")
|
|
uploadPage.busy = false
|
|
uploadPage.setup.enabled = false
|
|
uploadPage.setup.installReady = false
|
|
assert.match(uploadPage.uploadReason, /installed/)
|
|
uploadPage.setup.installReady = true
|
|
uploadPage.feed.setEnabled = async () => false
|
|
const beforeDeniedEnable = uploadCalls.length
|
|
assert.equal(await uploadPage.upload(), false)
|
|
assert.equal(uploadCalls.length, beforeDeniedEnable, "failed enable never uploads")
|
|
console.log("Android Auto: APK/XAPK/APKM uploads in either road state, enable/upload sequencing, explicit blockers and feed boundaries passed")
|
|
assert.match(AndroidAutoPage.template, /<section[^>]*:inert="busy"[^>]*:aria-busy="busy \|\| undefined"/)
|
|
assert.match(AndroidAutoPage.template, /@click="openInstall">Update/)
|
|
assert.match(AndroidAutoPage.template, /Turning Android Auto on or off, package uploads, package deletion, Connect, and Disconnect work on-road and off-road/)
|
|
const removePage = page({ ...setup, enabled: false, parked: false })
|
|
removePage.runtime = { running: true }
|
|
removePage.feed = { async removePackage() { return true } }
|
|
assert.equal(removePage.removeReason, "")
|
|
removePage.openRemove()
|
|
assert.equal(removePage.removeOpen, true)
|
|
assert.equal(await removePage.removePackage(), true)
|
|
assert.equal(removePage.removeOpen, false)
|
|
assert.match(AndroidAutoPage.template, /Android Auto will disconnect first if connected/)
|
|
|
|
for (const parked of [true, false]) {
|
|
const consentPage = page({ ...setup, parked, enabled: false, bluetoothEnabled: false })
|
|
const consentCalls = []
|
|
consentPage.feed = {
|
|
async setEnabled(enabled, enableBluetooth) {
|
|
consentCalls.push(["enable", enabled, enableBluetooth])
|
|
consentPage.setup.enabled = enabled
|
|
consentPage.setup.bluetoothEnabled = enableBluetooth
|
|
return true
|
|
},
|
|
async upload(file) { consentCalls.push(["upload", file]); return true },
|
|
}
|
|
const canceled = consentPage.setEnabled(true)
|
|
assert.equal(consentPage.enableOpen, true)
|
|
assert.deepEqual(consentCalls, [], "opening the modal must not change either setting")
|
|
consentPage.cancelEnable()
|
|
assert.equal(await canceled, false)
|
|
assert.equal(consentPage.enableOpen, false)
|
|
assert.deepEqual(consentCalls, [])
|
|
assert.equal(consentPage.setup.enabled, false)
|
|
assert.equal(consentPage.setup.bluetoothEnabled, false)
|
|
consentPage.packageFile = { name: "android-auto.apk", size: 123 }
|
|
const canceledUpload = consentPage.upload()
|
|
assert.equal(consentPage.enableOpen, true)
|
|
consentPage.cancelEnable()
|
|
assert.equal(await canceledUpload, false)
|
|
assert.deepEqual(consentCalls, [], "canceling Turn On and Install must not upload")
|
|
const confirmedUpload = consentPage.upload()
|
|
assert.deepEqual(consentCalls, [])
|
|
assert.equal(await consentPage.confirmEnable(), true)
|
|
assert.equal(await confirmedUpload, true)
|
|
assert.deepEqual(consentCalls, [["enable", true, true], ["upload", consentPage.packageFile]])
|
|
assert.equal(consentPage.enableOpen, false)
|
|
}
|
|
const failedConsent = page({ ...setup, enabled: false, bluetoothEnabled: false })
|
|
failedConsent.feed = { async setEnabled() { return false }, async upload() { assert.fail("uploaded after failed Bluetooth startup") } }
|
|
failedConsent.packageFile = { name: "android-auto.apk", size: 123 }
|
|
const failedUpload = failedConsent.upload()
|
|
assert.equal(await failedConsent.confirmEnable(), false)
|
|
assert.equal(await failedUpload, false)
|
|
assert.equal(failedConsent.enableOpen, false)
|
|
assert.equal(failedConsent.setup.enabled, false)
|
|
assert.match(AndroidAutoPage.template, /Android Auto requires Bluetooth for setup and connecting to your car/)
|
|
assert.match(AndroidAutoPage.template, /Enable Bluetooth and Turn On/)
|
|
console.log("Android Auto: Bluetooth consent modal, cancellation, onroad enabling, and install continuation passed")
|
|
|
|
class UploadRequest {
|
|
upload = {}; status = 202; responseText = '{"ok":true}'; headers = {}; sent = null
|
|
open(method, path) { this.method = method; this.path = path }
|
|
setRequestHeader(key, value) { this.headers[key] = value }
|
|
send(value) { this.sent = value }
|
|
abort() { this.onabort() }
|
|
}
|
|
for (const event of ['load', 'error', 'timeout', 'abort']) {
|
|
const xhr = new UploadRequest(), controller = new AbortController(), progress = []
|
|
const body = { size: 20 * 1024 * 1024 }
|
|
const upload = uploadPackage('./api/android-auto/upload', { body, signal: controller.signal }, p => progress.push(p), () => xhr)
|
|
assert.equal(xhr.sent, body); assert.equal(xhr.withCredentials, true)
|
|
assert.equal(xhr.headers['Content-Type'], 'application/octet-stream')
|
|
xhr.upload.onprogress({ lengthComputable: true, loaded: body.size / 2, total: body.size })
|
|
assert.equal(progress[0].loaded, body.size / 2)
|
|
if (event === 'abort') controller.abort(); else xhr['on' + event]()
|
|
if (event === 'load') assert.deepEqual(await (await upload).json(), { ok: true })
|
|
else await assert.rejects(upload, /interrupted|timed out|canceled/)
|
|
}
|
|
const alreadyAborted = new AbortController(); alreadyAborted.abort()
|
|
const neverSent = new UploadRequest()
|
|
await assert.rejects(uploadPackage('upload', { signal: alreadyAborted.signal }, () => {}, () => neverSent), /canceled/)
|
|
assert.equal(neverSent.sent, null)
|
|
assert.doesNotMatch(AndroidAutoPage.template, /v-if="!localAccess"|Open this comma directly/)
|
|
console.log('Remote-capable binary uploader: progress, credentials, failure, timeout and cancellation passed')
|
|
|
|
// Compile the shipped Vue template, not only its strings: plain <template>
|
|
// renders an inert DOM node and hides every setup control in real browsers.
|
|
const { compile } = await import('../web/vendor/vue/vue.esm-browser.js')
|
|
const render = compile(AndroidAutoPage.template, { decodeEntities: value => value })
|
|
const vm = page({ ...setup, identity: { ...setup.identity, installed: false } })
|
|
vm.mode = 'local'
|
|
vm.installOpen = true // the package picker lives in the install sheet, shown only before setup
|
|
const tree = render(vm, [])
|
|
const tags = []
|
|
function visit(node) {
|
|
if (!node || typeof node !== 'object') return
|
|
if (typeof node.type === 'string') tags.push(node.type)
|
|
if (Array.isArray(node.children)) node.children.forEach(visit)
|
|
}
|
|
visit(tree)
|
|
assert(!tags.includes('template'), 'setup cannot live inside an inert template element')
|
|
assert(tags.includes('input') && tags.includes('button'), 'setup renders package selection and actions')
|
|
// Once installed, Manage package shows status and removal only; setup needs removal first.
|
|
const managed = page({ ...setup, identity: { ...setup.identity, installed: true } })
|
|
managed.installOpen = true
|
|
const managedTags = []
|
|
const collect = (node) => { if (!node || typeof node !== 'object') return; if (typeof node.type === 'string') managedTags.push(node.type); if (Array.isArray(node.children)) node.children.forEach(collect) }
|
|
collect(render(managed, []))
|
|
assert(!managedTags.includes('input'), 'manage view offers no setup inputs')
|
|
assert(managedTags.includes('dl'), 'manage view shows status')
|
|
for (const [field, reason] of [['installReady', /display|encoder/],
|
|
['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) {
|
|
const blocked = page({ ...setup, [field]: false })
|
|
assert.match(blocked.pairingReason, reason)
|
|
}
|
|
assert.equal(page(setup).pairingReason, '')
|
|
// Pairing needs no package; only projection does, and Connect opens the installer instead.
|
|
const noPackage = page({ ...setup, identity: { installed: false } })
|
|
assert.equal(noPackage.pairingReason, '')
|
|
noPackage.selected = { address: "AA:BB:CC:DD:EE:FF", name: "Car" }
|
|
noPackage.control = () => assert.fail("projection must not start without a package")
|
|
assert.equal(noPackage.connect(), false)
|
|
assert.equal(noPackage.installOpen, true)
|
|
// Turned off is no blocker either: Find My Car enables Android Auto, then pairs once the service is up.
|
|
const off = page({ ...setup, enabled: false, serviceReady: false, identity: { installed: false } })
|
|
assert.equal(off.pairingReason, '')
|
|
off.feed = { async setEnabled(value) { assert.equal(value, true); return true }, action: async () => assert.fail("paired before the service was up") }
|
|
assert.equal(await off.startPairing(), false)
|
|
assert.equal(off.pairWhenReady, true)
|
|
|
|
// Step 1 status: expiry wording by remaining days, and the expired date from the certificate.
|
|
assert.deepEqual(expiryInfo({ installed: true, expires: "2027-10-12T00:00:00+00:00", days_left: 372 }, "en-US"),
|
|
{ level: "ready", label: "Ready · Valid until Oct 12, 2027" })
|
|
assert.deepEqual(expiryInfo({ installed: true, expires: "2027-04-12T00:00:00+00:00", days_left: 9 }, "en-US"),
|
|
{ level: "soon", label: "Expires in 9 days (Apr 12)" })
|
|
assert.deepEqual(expiryInfo({ installed: false, expired: true, expires: "2026-04-12T00:00:00+00:00" }, "en-US"),
|
|
{ level: "expired", label: "Expired on Apr 12, 2026" })
|
|
assert.equal(expiryInfo({ installed: false, error: "unusable" }).level, "broken")
|
|
assert.equal(expiryInfo({ installed: false }).level, "none")
|
|
|
|
// Wrong downloads are caught by name before the upload; the real APKMirror file name passes.
|
|
assert.equal(packagePreflight({ name: "com.google.android.projection.gearhead_17.6.663454-release-2025-apkmirror.com.apk" }), "")
|
|
assert.match(packagePreflight({ name: "APKMirror Installer (Official)_1.6.apk" }), /Installer/)
|
|
assert.match(packagePreflight({ name: "com.google.android.gms_24.apk" }), /Play Services/)
|
|
assert.equal(packagePreflight({ name: "android-auto.zip" }), "")
|
|
const wrongPage = page(setup)
|
|
wrongPage.choosePackage({ target: { files: [{ name: "APKMirror Installer.apk", size: 10 }] } })
|
|
assert.match(wrongPage.uploadReason, /Installer/)
|
|
|
|
// Server rejection codes become friendly messages and a failed checklist row.
|
|
assert.match(importProblem({ state: "failed", code: "WRONG_APP" }, "Maps.apk"), /Wrong app selected \(Maps\.apk\)/)
|
|
assert.match(importProblem({ state: "failed", code: "WRONG_APP" }, "APKMirror Installer.apk"), /Installer/)
|
|
assert.match(importProblem({ state: "failed", code: "EXPIRED", expires: "2026-04-12T00:00:00+00:00" }, "", "en-US"), /expired on Apr 12, 2026/)
|
|
assert.match(importProblem({ state: "failed", code: "CORRUPT" }), /incomplete or damaged/)
|
|
assert.equal(importProblem({ state: "failed", code: "UNSUPPORTED_VERSION", error: "Use version from server" }), "Use version from server")
|
|
assert.equal(importProblem({ state: "running" }), "")
|
|
const statuses = (job) => installChecks(job, "en-US").map((check) => check.status)
|
|
assert.deepEqual(statuses({ state: "running", stage: "searching" }), ["done", "active", "pending", "pending"])
|
|
assert.deepEqual(statuses({ state: "running", stage: "installing" }), ["done", "done", "done", "active"])
|
|
assert.deepEqual(statuses({ state: "failed", stage: "searching", code: "WRONG_APP" }), ["done", "failed", "pending", "pending"])
|
|
assert.deepEqual(statuses({ state: "failed", stage: "verifying", code: "EXPIRED" }), ["done", "done", "done", "failed"])
|
|
const done = installChecks({ state: "done", stage: "done", expires: "2027-10-12T00:00:00+00:00" }, "en-US")
|
|
assert.deepEqual(done.map((check) => check.status), ["done", "done", "done", "done"])
|
|
assert.equal(done[3].label, "Certificate valid until October 12, 2027")
|
|
|
|
// The install sheet follows only the import this page started, not an older result.
|
|
const sheet = page({ ...setup, import: { state: "failed", code: "WRONG_APP", started: 1 } })
|
|
assert.equal(sheet.installState, "choose")
|
|
sheet.installAttempted = true; sheet.installBaseline = 1
|
|
assert.equal(sheet.installState, "choose")
|
|
sheet.setup.import = { state: "running", stage: "reading", started: 2 }
|
|
assert.equal(sheet.installState, "checking")
|
|
sheet.setup.import = { state: "done", stage: "done", started: 2 }
|
|
assert.equal(sheet.installState, "done")
|
|
console.log("Android Auto: expiry badges, package preflight, friendly rejections and install checklist passed")
|
|
|
|
// Enabling can finish its refresh after the service is already ready.
|
|
const { reactive, watch, nextTick } = await import('../web/vendor/vue/vue.esm-browser.js')
|
|
const readyPage = reactive(page({ ...setup, enabled: false, serviceReady: false }))
|
|
let pairingCalls = 0
|
|
readyPage.feed = {
|
|
async setEnabled() {
|
|
readyPage.setup = { ...readyPage.setup, enabled: true, serviceReady: true }
|
|
await nextTick()
|
|
return true
|
|
},
|
|
async action() { pairingCalls++; return true },
|
|
}
|
|
const stopReadyWatch = watch(() => readyPage.setup.serviceReady, () => AndroidAutoPage.watch['setup.serviceReady'].call(readyPage))
|
|
assert.equal(await readyPage.startPairing(), true)
|
|
assert.equal(pairingCalls, 1)
|
|
assert.equal(readyPage.pairWhenReady, false)
|
|
stopReadyWatch()
|
|
const connectingPage = page(setup)
|
|
connectingPage.runtime = { running: true, state: 'connecting_bluetooth' }
|
|
assert.equal(connectingPage.projecting, false)
|
|
connectingPage.runtime.state = 'streaming'
|
|
assert.equal(connectingPage.projecting, true)
|
|
|
|
assert.match(AndroidAutoPage.template, /Remove Android Auto support/)
|
|
assert.match(AndroidAutoPage.template, /Manage package/)
|
|
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
try {
|
|
for (const [browser, mobile] of [
|
|
[{ userAgent: 'Mozilla/5.0 (iPhone)' }, true],
|
|
[{ userAgent: 'Mozilla/5.0 (Linux; Android 14)', userAgentData: { mobile: false } }, true],
|
|
[{ userAgent: 'Mozilla/5.0 (Macintosh)', platform: 'MacIntel', maxTouchPoints: 5 }, true],
|
|
[{ userAgent: 'Mozilla/5.0 (Macintosh)', platform: 'MacIntel', maxTouchPoints: 0 }, false],
|
|
]) {
|
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: browser })
|
|
assert.equal(AndroidAutoPage.computed.onPhone(), mobile, 'detect phones and tablets without blocking desktop browsers')
|
|
}
|
|
} finally {
|
|
if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor)
|
|
else delete globalThis.navigator
|
|
}
|
|
console.log('Android Auto page: manage view, pairing gates and phone detection passed')
|