refactor(voice): isolate gemini live provider core

This commit is contained in:
manjunath committed 2026-10-19 21:40:40 +05:30
commit 10916227cb
655 files changed
+45946 -26483

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/**
* Release-time assembly of changelog fragments.
*
* A pull request adds `changelog.d/<pull request>-<slug>.md` instead of editing
* `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in
* flight no longer write into the same few lines at the top of one file. A
* release splices the fragments in and empties the directory.
*
* Fragment text is copied through unchanged apart from trailing whitespace:
* `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a
* fixed window after a heading, so reflowing an entry here could move text
* across that window.
*
* A write is staged so that a failure at any point leaves a rerun unable to
* duplicate or lose an entry. The assembled file is written into
* `changelog.d/.assembling/` first, the fragments are renamed in beside it, and
* renaming the assembled file over `CHANGELOG.md` is the single commit point.
* While the assembled file is still staged the batch has not landed, so
* recovery moves the fragments back; once it is gone the batch has landed, and
* the staged fragments are leftovers to delete. Either way they are out of the
* input directory, so they are never assembled twice.
*/
export const FRAGMENT_DIR = 'changelog.d';
export const STAGING_DIR = '.assembling';
const STAGED_CHANGELOG = 'CHANGELOG.md';
const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
const HEADING = '# Changelog\n';
/** Fragment file names a release may consume, highest pull request first. */
export function orderFragmentNames(names) {
const claimed = new Map();
for (const name of names) {
if (name === 'README.md') continue;
const match = FRAGMENT_NAME.exec(name);
if (!match) {
throw new Error(
`Unexpected file in ${FRAGMENT_DIR}/: ${name}` +
' — expected <pull request>-<slug>.md with a lowercase slug',
);
}
const number = Number(match[1]);
const prior = claimed.get(number);
if (prior) {
throw new Error(
`Two fragments claim pull request ${number}: ${prior} and ${name}`,
);
}
claimed.set(number, name);
}
return [...claimed.entries()]
.sort(([a], [b]) => b - a)
.map(([, name]) => name);
}
/** Splice fragment bodies above the existing entries, newest batch on top. */
export function assembleChangelog(changelog, fragments) {
if (!fragments.length) return changelog;
if (!changelog.startsWith(HEADING)) {
throw new Error('CHANGELOG.md must start with "# Changelog"');
}
const entries = changelog.slice(HEADING.length).replace(/^\n+/, '');
const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n');
return `${HEADING}\n${batch}\n\n${entries}`;
}
/**
* Finish or roll back a batch an earlier `--write` left in the staging
* directory. A no-op when there is none.
*
* @returns {Promise<'none'|'rolled-back'|'cleaned-up'>}
*/
export async function recoverInterruptedBatch(root, io = fs) {
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
let staged;
try {
staged = await io.readdir(staging);
} catch (error) {
if (error.code === 'ENOENT') return 'none';
throw error;
}
if (!staged.includes(STAGED_CHANGELOG)) {
// The assembled file was renamed over CHANGELOG.md: the batch landed.
await io.rm(staging, { recursive: true, force: true });
return 'cleaned-up';
}
// The batch never reached CHANGELOG.md, so every fragment goes back.
const pending = new Set(await io.readdir(directory));
const fragments = staged.filter((name) => name !== STAGED_CHANGELOG);
const clash = fragments.filter((name) => pending.has(name));
if (clash.length) {
throw new Error(
`Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` +
` also exist in ${FRAGMENT_DIR}/ — resolve by hand`,
);
}
for (const name of fragments) {
await io.rename(path.join(staging, name), path.join(directory, name));
}
await io.rm(staging, { recursive: true, force: true });
return 'rolled-back';
}
/**
* Report or apply the pending fragments in `<root>/changelog.d`.
*
* The batch is validated in full before anything is written, so a malformed
* filename or a duplicate pull request number leaves the directory untouched.
* `--write` first recovers any batch an interrupted run left staged.
*
* @param {string} root repository root
* @param {'--check'|'--write'} mode `--check` reports and changes nothing
* @param {typeof fs} [io] filesystem calls, replaceable for failure injection
* @returns {Promise<{ consumed: string[], changelog: string }>}
*/
export async function assembleFragments(root, mode, io = fs) {
if (!['--check', '--write'].includes(mode)) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
const changelogFile = path.join(root, 'CHANGELOG.md');
if (mode === '--write') {
await recoverInterruptedBatch(root, io);
}
const names = await io.readdir(directory);
if (names.includes(STAGING_DIR)) {
throw new Error(
`${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` +
' left behind — `npm run changelog` finishes or rolls it back',
);
}
const consumed = orderFragmentNames(names);
const bodies = [];
for (const name of consumed) {
bodies.push(await io.readFile(path.join(directory, name), 'utf8'));
}
const changelog = assembleChangelog(
await io.readFile(changelogFile, 'utf8'),
bodies,
);
if (mode === '--write' && consumed.length) {
try {
await io.mkdir(staging);
await io.writeFile(
path.join(staging, STAGED_CHANGELOG),
changelog,
'utf8',
);
for (const name of consumed) {
await io.rename(path.join(directory, name), path.join(staging, name));
}
await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile);
await io.rm(staging, { recursive: true, force: true });
} catch (error) {
try {
await recoverInterruptedBatch(root, io);
} catch {
throw new Error(
`${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` +
' and the next `npm run changelog` finishes or rolls it back',
{ cause: error },
);
}
throw error;
}
}
return { consumed, changelog };
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
if (process.argv.length !== 3) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const mode = process.argv[2];
const { consumed } = await assembleFragments(
fileURLToPath(new URL('../', import.meta.url)),
mode,
);
if (!consumed.length) {
console.log(`No fragments in ${FRAGMENT_DIR}/.`);
} else {
console.log(
`${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` +
`\n ${consumed.join('\n ')}`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+1 -13
View File
@@ -56,7 +56,6 @@ KEY_SETUP_EXTERNAL_KEYS=()
[[ -n "${GOOGLE_MAPS_SERVER_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GOOGLE_MAPS_SERVER_API_KEY)
[[ -n "${CESIUM_ION_TOKEN:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(CESIUM_ION_TOKEN)
[[ -n "${OPENAI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(OPENAI_API_KEY)
[[ -n "${GEMINI_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(GEMINI_API_KEY)
[[ -n "${AISSTREAM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(AISSTREAM_API_KEY)
[[ -n "${FIRMS_MAP_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(FIRMS_MAP_KEY)
[[ -n "${TOMTOM_API_KEY:-}" ]] && KEY_SETUP_EXTERNAL_KEYS+=(TOMTOM_API_KEY)
@@ -234,9 +233,6 @@ resolve_opensky_credentials
# Add to Keychain with e.g.:
# security add-generic-password -U -s "openai-api" -a "api-key" -w
OPENAI_API_KEY="${OPENAI_API_KEY:-$(read_dotenv_value "OPENAI_API_KEY")}"
# Gemini uses its own voice credential, never the Google Maps key or Keychain.
GEMINI_API_KEY="${GEMINI_API_KEY:-$(read_dotenv_value "GEMINI_API_KEY")}"
GEMINI_LIVE_MODEL="${GEMINI_LIVE_MODEL:-$(read_dotenv_value "GEMINI_LIVE_MODEL")}"
AISSTREAM_API_KEY="${AISSTREAM_API_KEY:-$(read_dotenv_value "AISSTREAM_API_KEY")}"
CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_dotenv_value "CESIUM_ION_TOKEN")}"
LL2_API_TOKEN="${LL2_API_TOKEN:-$(read_dotenv_value "LL2_API_TOKEN")}"
@@ -351,13 +347,7 @@ case "${OPENSKY_AUTH_MODE}" in
echo "OpenSky auth: disabled (anonymous mode)"
;;
esac
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set"
[[ -n "${GEMINI_API_KEY}" ]] && echo "Gemini key (voice): configured" || echo "Gemini key (voice): not set"
if [[ -n "${OPENAI_API_KEY}" || -n "${GEMINI_API_KEY}" ]]; then
echo "Voice control: available"
else
echo "Voice control: add an OpenAI or Gemini key, or select a configured OpenAI provider in Provider Settings"
fi
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC off unless a Codex ChatGPT sign-in is selected in Provider Settings (experimental)"
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
@@ -425,8 +415,6 @@ put_env_if_set OPENSKY_CLIENT_SECRET "${OPENSKY_CLIENT_SECRET}"
put_env_if_set OPENSKY_USERNAME "${OPENSKY_USERNAME}"
put_env_if_set OPENSKY_PASSWORD "${OPENSKY_PASSWORD}"
put_env_if_set OPENAI_API_KEY "${OPENAI_API_KEY}"
put_env_if_set GEMINI_API_KEY "${GEMINI_API_KEY}"
put_env_if_set GEMINI_LIVE_MODEL "${GEMINI_LIVE_MODEL}"
put_env_if_set AISSTREAM_API_KEY "${AISSTREAM_API_KEY}"
put_env_if_set CESIUM_ION_TOKEN "${CESIUM_ION_TOKEN}"
put_env_if_set TOMTOM_API_KEY "${TOMTOM_API_KEY}"
+14 -19
View File
@@ -96,6 +96,7 @@
"server/providers/space/launch-library.js",
"src/data/spaceProviderRequests.js",
"src/tooling/spaceProviders.test.mjs",
"src/tooling/ll2Integration.test.mjs",
"server/providers/terrain.js",
"server/providers/traffic.js",
"server/providers/firms.js",
@@ -117,6 +118,7 @@
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
"src/data/cctvMissingNumbers.test.mjs",
"server/providers/cctv/sources.js",
"server/providers/radio.js",
"server/providers/radio/catalog.js",
@@ -445,6 +447,7 @@
"src/data/rocketLaunches.js",
"src/layers/satellites/source.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
@@ -634,6 +637,7 @@
"src/voice/control.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
@@ -689,6 +693,7 @@
"src/ui/shareRestoration.js",
"src/ui/visualSettings.js",
"src/ui/panelChrome.js",
"src/ui/panelChrome.radioOwnership.test.mjs",
"src/testSupport/readShellSource.mjs",
"src/ui/stateOwners.test.mjs",
"src/layers/flights/records.js",
@@ -731,6 +736,7 @@
"src/voice/session.test.mjs",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/layers/earthquakes/records.test.mjs",
"src/sources/sourceSlot.js",
"src/data/feedState.js",
"src/app/localGeojsonServices.js",
@@ -872,9 +878,13 @@
"src/layers/weather/imageryHost.test.mjs",
"src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs",
"src/sdr/gain.test.mjs",
"src/sdr/readPipeline.diag.test.mjs",
"src/sdr/sdrAudioWorklet.test.mjs",
"src/sdr/spectrum.test.mjs",
"src/sdr/usbDevices.test.mjs",
"src/sdr/workerAdmission.test.mjs",
"src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs",
@@ -1020,6 +1030,7 @@
"scripts/qa-context-arrivals.mjs",
"src/tools/catalog.test.mjs",
"src/tools/area.test.mjs",
"src/tools/results.test.mjs",
"src/tools/queries/queries.test.mjs",
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
@@ -1054,24 +1065,8 @@
"src/tooling/webAppIcons.test.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs",
"src/voice/commands.test.mjs",
"src/voice/providerLifetime.test.mjs",
"src/voice/providerCapabilities.js",
"src/voice/providerCapabilities.test.mjs",
"src/voice/providerIntent.js",
"src/voice/providerIntent.test.mjs",
"src/voice/providerIntentFragments.js",
"src/voice/providerIntentFragments.test.mjs",
"src/voice/toolExecutionPolicy.js",
"src/voice/toolExecutionPolicy.test.mjs",
"src/voice/userTurnIntent.js",
"src/voice/userTurnIntent.test.mjs",
"scripts/qa-gemini-voice.mjs",
"src/geminiServer.test.mjs",
"src/voice/geminiAudio.test.mjs",
"src/voice/geminiAutomaticVad.test.mjs",
"src/voice/geminiSession.test.mjs",
"src/voice/integrationOwnership.test.mjs",
"src/voice/providerCommands.test.mjs"
"src/terrainHeightsBounds.test.mjs"
]
+623
View File
@@ -0,0 +1,623 @@
// Keeps the locale catalogs in step with the code.
//
// npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
//
// A translation left as the English fails unless src/i18n/same-as-english.json
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
'g',
);
function unquote(literal) {
const body = literal.slice(1, -1);
return body.replace(
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs,
(_, esc) => {
if (esc[0] === 'u' || esc[0] === 'x') {
return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16));
}
return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc;
},
);
}
const ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
function decodeHtml(text) {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
if (name[0] === '#') {
const hex = name[1] === 'x' || name[1] === 'X';
return String.fromCodePoint(
parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10),
);
}
return ENTITIES[name.toLowerCase()] ?? all;
});
}
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
function trackedFiles(...pathspecs) {
return (
execFileSync(
'git',
[
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'--',
...pathspecs,
],
{
cwd: ROOT,
encoding: 'utf8',
},
)
.split('\n')
.filter(Boolean)
// The index still lists a file moved or deleted without git mv / git rm.
.filter((file) => existsSync(path.join(ROOT, file)))
);
}
/**
* Every message the code can ask for.
* @returns {Map<string, {plural: boolean, one?: string, where: string}>}
*/
export function extractMessages() {
const messages = new Map();
const add = (text, where, plural = null) => {
if (!text) return;
const known = messages.get(text);
if (known && Boolean(known.plural) !== Boolean(plural)) {
throw new Error(
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
);
}
if (!known)
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
};
const sources = sourceFiles();
for (const file of sources) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
for (const match of source.matchAll(CALL)) {
const line = source.slice(0, match.index).split('\n').length;
const where = `${file}:${line}`;
if (match[1]) {
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
} else if (match[4]) {
if (match[4][0] === '`' && match[4].includes('${')) continue;
add(unquote(match[4]), where);
} else {
add(unquote(match[6]), where, { one: unquote(match[5]) });
}
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs))
add(normalize(decodeHtml(text)), where);
for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) {
const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`));
if (value) add(decodeHtml(value[1]), where);
else
throw new Error(
`${where}: data-i18n-${name} without a ${name} attribute`,
);
}
}
}
return messages;
}
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
const VOID_TAGS = new Set(
'area base br col embed hr img input link meta source track wbr'.split(' '),
);
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
const found = [];
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
c.replace(/[^\n]/g, ' '),
);
const TOKEN =
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
const stack = [];
const skipping = () => stack.at(-1)?.skip === true;
for (const match of source.matchAll(TOKEN)) {
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
if (text !== undefined) {
const top = stack.at(-1);
const value = normalize(decodeHtml(text));
if (!skipping() && hasWords(value) && !top?.marked)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: value,
});
continue;
}
const tag = rawTag.toLowerCase();
if (closing) {
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
if (at >= 0) stack.length = at;
continue;
}
const skip =
skipping() ||
SKIP_TAGS.has(tag) ||
/\stranslate="no"/.test(attrs) ||
/\sdata-i18n-runtime\b/.test(attrs) ||
/\saria-hidden="true"/.test(attrs) ||
/class="[^"]*material-symbols/.test(attrs);
if (!skip) {
for (const [, name, value] of attrs.matchAll(
/\s(title|aria-label|placeholder)="([^"]*)"/g,
)) {
if (
hasWords(value) &&
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: `${name}="${decodeHtml(value)}"`,
});
}
}
if (!VOID_TAGS.has(tag) && !selfClosing)
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
}
return found;
}
// Where a string becomes UI text. Each pattern ends where the value starts.
const JS_SINKS = [
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
// Also showToast?.(…) and this._showToast(…).
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops
// those, so only readable text is reported. A name right after `?` is a
// ternary branch (`ok ? status : 'Fallback'`), not a property.
/(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
];
// Layer status set by assignment: this._error = …, stats.lastError = …,
// error = … (a layer factory keeps its status in `let error`). Not checked:
// the declaration itself (`const error = …`, `let status = …`) and a default
// value in a parameter list or destructuring (see defaultValue).
const ASSIGNMENT_SINK =
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
JS_SINKS.push(ASSIGNMENT_SINK);
/**
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
*/
function defaultValue(source, nameStart, valueStart) {
const before = source.slice(0, nameStart).trimEnd().at(-1);
if (!before || !'([{,'.includes(before)) return false;
const end = valueStart + expressionAt(source, valueStart).length;
const after = source.slice(end).trimStart()[0];
return Boolean(after) && ',)]}'.includes(after);
}
const MARKERS = /\b(?:t|tn|msg)\(/y;
/** The expression starting at `start`, up to its end at bracket depth 0. */
function expressionAt(source, start) {
let depth = 0;
let i = start;
for (; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
i = skipString(source, i);
continue;
}
if ('([{'.includes(c)) depth++;
else if (')]}'.includes(c)) {
if (depth === 0) break;
depth--;
} else if (depth === 0 && (c === ';' || c === ',')) break;
else if (depth === 0 && c === '\n') {
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
break;
}
}
return source.slice(start, i);
}
function skipString(source, i) {
const quote = source[i];
for (let j = i + 1; j < source.length; j++) {
if (source[j] === '\\') j++;
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
let depth = 1;
for (j += 2; j < source.length && depth; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}') depth--;
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
}
j--;
} else if (source[j] === quote) return j;
else if (quote !== '`' && source[j] === '\n') return j;
}
return source.length;
}
/** String literals that sit in value position: not inside any call or index,
* not compared, and not already passed to t/tn/msg. */
function valueLiterals(expression) {
const literals = [];
let callDepth = 0;
const stack = [];
for (let i = 0; i < expression.length; i++) {
const c = expression[i];
if (c === '(' || c === '[') {
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
stack.push(isCall);
if (isCall) callDepth++;
continue;
}
if (c === ')' || c === ']') {
if (stack.pop()) callDepth--;
continue;
}
MARKERS.lastIndex = i;
if (MARKERS.test(expression)) {
stack.push(true);
callDepth++;
i = MARKERS.lastIndex - 1;
continue;
}
if (c !== "'" && c !== '"' && c !== '`') continue;
const end = skipString(expression, i);
const literal = expression.slice(i, end + 1);
const before = expression.slice(0, i).trimEnd();
const after = expression.slice(end + 1).trimStart();
const compared =
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
/^(===|!==|==|!=|in\b)/.test(after);
if (callDepth === 0 && !compared) literals.push(literal);
i = end;
}
return literals;
}
/** The text a literal shows: static template parts, HTML tags removed. */
function visibleText(literal, html) {
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
if (html) {
const attrs = [
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
].map((m) => m[1]);
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
}
const text = normalize(body);
if (!hasWords(text)) return null;
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
return text;
}
/** Blank out comments, keeping strings and line numbers intact. */
function withoutComments(source) {
let out = '';
for (let i = 0; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
const end = skipString(source, i);
out += source.slice(i, end + 1);
i = end;
} else if (c === '/' && source[i + 1] === '/') {
const end = source.indexOf('\n', i);
const stop = end < 0 ? source.length : end;
out += ' '.repeat(stop - i);
i = stop - 1;
} else if (c === '/' && source[i + 1] === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end < 0 ? source.length : end + 2;
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop - 1;
} else out += c;
}
return out;
}
export function unmarkedScript(file, original) {
const found = [];
const lines = original.split('\n');
const source = withoutComments(original);
const ignored = (line) =>
/i18n-ignore/.test(lines[line - 1] ?? '') ||
/i18n-ignore/.test(lines[line - 2] ?? '');
for (const sink of JS_SINKS) {
sink.lastIndex = 0;
for (const match of source.matchAll(sink)) {
const start = match.index + match[0].length;
const line = lineAt(source, start);
if (ignored(line)) continue;
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
continue;
const html = /HTML/.test(match[0]);
for (const literal of valueLiterals(expressionAt(source, start))) {
const text = visibleText(literal, html);
if (text) found.push({ where: `${file}:${line}`, text });
}
}
}
return found;
}
/**
* UI text that is not marked for translation: markup without data-i18n* and
* string literals that reach the page without t(), tn() or msg().
* Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
* @returns {{where: string, text: string}[]}
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
found.push(
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
const seen = new Set();
return found.filter(({ where, text }) => {
const key = `${where}\0${text}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
function sourceFiles() {
return trackedFiles(
'src/*.js',
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
!/\.test\.mjs$|\/testSupport\//.test(file),
);
}
const placeholders = (text) =>
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
.map((m) => m[1])
.sort()
.join(',');
export function catalogPath(locale) {
return path.join(LOCALES_DIR, `${locale}.json`);
}
export function readCatalog(locale) {
return JSON.parse(readFileSync(catalogPath(locale), 'utf8'));
}
export function shippedLocales() {
return trackedFiles('src/i18n/locales/*.json').map((file) =>
path.basename(file, '.json'),
);
}
/** Messages a locale keeps exactly as in English, on purpose. */
export function readSameAsEnglish(locale) {
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
return new Set(lists[locale] || []);
}
/** The English a message shows: its text without the "context|" prefix. */
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
/** A translation with nothing to show: t() falls back to English for it. */
const blank = (value) => value.trim() === '';
/** Words to read, beyond {placeholders}, codes and punctuation. */
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
const STILL_ENGLISH = (text, locale) =>
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
* @returns {{missing: string[], stale: string[], errors: string[]}}
*/
export function checkCatalog(
locale,
catalog,
messages,
sameAsEnglish = readSameAsEnglish(locale),
) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const missing = [];
const errors = [];
for (const [text, info] of messages) {
if (!Object.hasOwn(catalog, text)) {
missing.push(text);
continue;
}
const entry = catalog[text];
if (info.plural) {
if (!entry || typeof entry !== 'object') {
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
continue;
}
const english = new Set([info.one, englishOf(text)]);
const stillEnglish = [];
for (const category of categories) {
const form = entry[category];
if (typeof form !== 'string')
errors.push(`"${text}" lacks the "${category}" form`);
else if (blank(form))
errors.push(`"${text}" [${category}] is empty: translate it`);
else if (placeholders(form) !== placeholders(text))
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
else if (english.has(form) && hasLetters(form))
stillEnglish.push(category);
}
if (stillEnglish.length && !sameAsEnglish.has(text))
errors.push(
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
);
} else if (typeof entry !== 'string') {
errors.push(`"${text}" must be a string`);
} else if (blank(entry)) {
errors.push(`"${text}" is empty: translate it`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
hasLetters(entry) &&
!sameAsEnglish.has(text)
) {
errors.push(STILL_ENGLISH(text, locale));
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
return { missing, stale, errors };
}
/**
* What `--missing <locale>` prints: every missing entry, ready to translate.
* A template, not a translation: the check rejects a string left empty and a
* plural form left as the English.
*/
export function missingTemplate(locale, catalog, messages) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const out = {};
for (const text of checkCatalog(locale, catalog, messages, new Set())
.missing) {
const info = messages.get(text);
out[text] = info.plural
? Object.fromEntries(
categories.map((category) => [
category,
category === 'one' ? info.one : text,
]),
)
: '';
}
return out;
}
function main(args) {
const messages = extractMessages();
const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1]
: null;
if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2));
return 0;
}
let failed = false;
for (const locale of shippedLocales()) {
let catalog = readCatalog(locale);
const { missing, stale, errors } = checkCatalog(locale, catalog, messages);
if (args.includes('--prune') && stale.length) {
catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)),
);
writeFileSync(
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) {
failed = true;
console.log(
`${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`,
);
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
}
if (missing.length) {
failed = true;
console.log(
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
);
for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
}
for (const error of errors) {
failed = true;
console.log(`${locale}: ${error}`);
}
if (!missing.length && !stale.length && !errors.length) {
console.log(`${locale}: ${messages.size} messages, complete`);
}
}
const unmarked = findUnmarked();
if (unmarked.length) {
failed = true;
console.log(
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
);
for (const { where, text } of unmarked)
console.log(` ${where} ${JSON.stringify(text)}`);
}
return failed ? 1 : 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
File diff suppressed because it is too large. Load diff
-4
View File
@@ -11,8 +11,6 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'GOOGLE_MAPS_SERVER_API_KEY',
'CESIUM_ION_TOKEN',
'OPENAI_API_KEY',
'GEMINI_API_KEY',
'GEMINI_LIVE_MODEL',
'AISSTREAM_API_KEY',
'FIRMS_MAP_KEY',
'TOMTOM_API_KEY',
@@ -20,7 +18,6 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
'OPENSKY_CLIENT_SECRET',
'LL2_API_TOKEN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GEMINI_PER_MIN',
'GEV_RATELIMIT_GOOGLE_PER_MIN',
'PINOKIO_SHARE_CLOUDFLARE',
'PINOKIO_SHARE_LOCAL',
@@ -29,7 +26,6 @@ export const PINOKIO_CONFIG_FIELDS = Object.freeze([
const PINOKIO_DEFAULTS = Object.freeze({
GEV_RATELIMIT_OPENAI_PER_MIN: '30',
GEV_RATELIMIT_GEMINI_PER_MIN: '30',
GEV_RATELIMIT_GOOGLE_PER_MIN: '120',
PINOKIO_SHARE_CLOUDFLARE: 'false',
PINOKIO_SHARE_LOCAL: 'false',
+2 -2
View File
@@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) {
export function installPinokioDependencies() {
applyPinokioEnvironment();
rmSync(READY_FILE, { force: true });
const npm = npmProcessSpec();
runChecked(npm.command, ['ci'], { shell: npm.shell });
const npm = npmProcessSpec(['ci']);
runChecked(npm.command, npm.args, { shell: npm.shell });
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env node
/** Real Gemini acceptance: feed a WAV through Chromium's microphone and await spoken output.
* Example: node scripts/qa-gemini-voice.mjs --wav path/to/question.wav --expect-response 'four|4'
* Requires a configured Gemini key in the running app; never reads the permanent key.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const option = (name, fallback) => {
const index = args.indexOf(name);
return index < 0 ? fallback : args[index + 1];
};
const appUrl = new URL(option('--url', 'http://127.0.0.1:43981/'));
appUrl.searchParams.set('welcome', '0');
const wav = option('--wav');
if (!wav)
throw new Error(
'Pass --wav with a PCM WAV containing the spoken test request.',
);
const wavPath = path.resolve(wav);
const fixture = await fs.readFile(wavPath);
if (
fixture.toString('ascii', 0, 4) !== 'RIFF' ||
fixture.toString('ascii', 8, 12) !== 'WAVE'
)
throw new Error('The microphone fixture must be a WAV file.');
const expectedResponse = option('--expect-response', 'four|4');
const expectedInput = option('--expect-input', 'two plus two|2 plus 2|2 \\+ 2');
const requiredAction = option('--expect-action', '');
const minimumHeight = Number(option('--min-height', '0'));
const outDir = path.resolve(option('--out', 'qa-shots/gemini-voice'));
await fs.mkdir(outDir, { recursive: true });
const browser = await puppeteer.launch({
headless: !args.includes('--headed'),
args: [
'--use-fake-device-for-media-stream',
`--use-file-for-fake-audio-capture=${wavPath}%noloop`,
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let result;
try {
const context = browser.defaultBrowserContext();
await context.overridePermissions(appUrl.origin, ['microphone']);
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
// Only the test input device is substituted. GEV still captures this browser
// MediaStream through its real AudioWorklet and calls the real Gemini API.
if (args.includes('--virtual-mic')) {
await page.evaluateOnNewDocument((base64) => {
const original = navigator.mediaDevices.getUserMedia.bind(
navigator.mediaDevices,
);
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints.audio || constraints.video)
return original(constraints);
const context = new AudioContext();
await context.resume();
const bytes = Uint8Array.from(atob(base64), (char) =>
char.charCodeAt(0),
);
const buffer = await context.decodeAudioData(bytes.buffer);
const source = context.createBufferSource();
source.buffer = buffer;
const destination = context.createMediaStreamDestination();
source.connect(destination);
const stream = destination.stream;
const track = stream.getAudioTracks()[0];
const stop = track.stop.bind(track);
track.stop = () => {
try {
source.stop();
} catch {
/* Already ended. */
}
source.disconnect();
destination.disconnect();
void context.close().catch(() => {});
stop();
};
source.start(context.currentTime + 0.25);
return stream;
};
}, fixture.toString('base64'));
}
const tokenStatuses = [];
page.on('response', (response) => {
if (new URL(response.url()).pathname === '/api/gemini/token')
tokenStatuses.push(response.status());
});
await page.goto(appUrl.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => {
const loading = document.getElementById('loading-screen');
return (
window.__godsEyeView?.voiceCommands &&
document.getElementById('gev-voice-provider') &&
(!loading ||
getComputedStyle(loading).display === 'none' ||
Number(getComputedStyle(loading).opacity) === 0)
);
},
{ timeout: 90000 },
);
await page.select('#gev-voice-provider', 'gemini');
await page.evaluate(() => {
window.__geminiAcceptance = {
transcripts: [],
inputSegments: [],
actions: [],
};
window.__godsEyeView.voiceCommands.session.subscribe((event) => {
if (
event.type === 'transcript' &&
event.role === 'user' &&
event.attribution === 'unverified' &&
event.delta === true &&
event.final === false &&
typeof event.text === 'string'
)
window.__geminiAcceptance.inputSegments.push(event.text);
if (event.type === 'transcript' && event.final)
window.__geminiAcceptance.transcripts.push({
role: event.role,
text: event.text,
});
if (event.type === 'action-result')
window.__geminiAcceptance.actions.push({
name: event.name,
ok: event.result?.ok === true,
});
});
});
const began = Date.now();
await page.click('#gev-voice-button');
let timedOut = false;
try {
await page.waitForFunction(
() => {
const voice = window.__godsEyeView.voiceCommands;
const d = voice.getDiagnostics();
return (
d.status === 'error' ||
(d.completedTurns > 0 &&
d.audio?.outputSamples > 0 &&
(d.audio.discardedOutputSamples > 0 ||
(d.audio.completedOutputSamples === d.audio.outputSamples &&
d.audio.queuedSeconds < 0.05)))
);
},
{ timeout: 75000 },
);
} catch {
timedOut = true;
}
const proof = await page.evaluate(() => ({
...window.__geminiAcceptance,
diagnostics: window.__godsEyeView.voiceCommands.getDiagnostics(),
provider: document.querySelector('#gev-voice-provider').value,
detail: document.querySelector('#gev-voice-detail').textContent,
cameraHeightM:
window.__godsEyeView.viewer.camera.positionCartographic.height,
}));
// Fixture-level caption evidence only: these unordered native segments do
// not identify a command, authorize a tool, or form a final user transcript.
const userText = proof.inputSegments.join('');
const assistantText = proof.transcripts
.filter((item) => item.role === 'assistant')
.map((item) => item.text)
.join(' ');
// A completed Radio handoff releases Gemini's speaker and microphone.
// Closed audio is valid only when the successful handoff is already idle.
const completedRadioHandoff =
proof.actions.some(
(action) => action.name === 'control_radio' && action.ok,
) &&
proof.diagnostics.status === 'idle' &&
!proof.diagnostics.connected &&
!proof.diagnostics.microphoneActive &&
proof.diagnostics.audio?.resourcesReleased === true;
const checks = {
geminiSelected: proof.provider === 'gemini',
tokenGranted: tokenStatuses.includes(200),
heardInput: new RegExp(expectedInput, 'i').test(userText),
expectedReply: new RegExp(expectedResponse, 'i').test(assistantText),
realMicrophoneFrames:
proof.diagnostics.audio?.inputChunks > 0 &&
proof.diagnostics.audio?.nonSilentInputChunks > 0,
playedReply:
proof.diagnostics.audio?.outputSamples > 0 &&
proof.diagnostics.audio.completedOutputSamples ===
proof.diagnostics.audio.outputSamples &&
proof.diagnostics.audio.discardedOutputSamples === 0 &&
proof.diagnostics.audio.queuedSeconds < 0.05 &&
(proof.diagnostics.audio.contextState === 'running' ||
(completedRadioHandoff &&
proof.diagnostics.audio.contextState === 'closed')),
completed:
!timedOut &&
proof.diagnostics.completedTurns > 0 &&
proof.diagnostics.status !== 'error',
action:
!requiredAction ||
proof.actions.some(
(action) => action.name === requiredAction && action.ok,
),
camera: !minimumHeight || proof.cameraHeightM >= minimumHeight,
};
await page.screenshot({ path: path.join(outDir, 'response.png') });
// Stop is idempotent; toggling the button could restart an idle session
// after Radio has taken the speaker between proof collection and cleanup.
await page.evaluate(() => window.__godsEyeView.voiceCommands.stop());
const stopped = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.getDiagnostics(),
);
checks.stopped =
stopped.status === 'idle' &&
!stopped.connected &&
!stopped.microphoneActive;
result = {
ok: Object.values(checks).every(Boolean),
microphoneSource: args.includes('--virtual-mic')
? 'Web Audio virtual microphone from macOS WAV'
: 'Chromium WAV capture device',
checks,
elapsedMs: Date.now() - began,
fixtureSha256: createHash('sha256').update(fixture).digest('hex'),
tokenStatuses,
...proof,
stopped,
};
await fs.writeFile(
path.join(outDir, 'result.json'),
JSON.stringify(result, null, 2) + '\n',
);
console.log(JSON.stringify(result, null, 2));
console.log(`VERDICT: ${result.ok ? 'PASS' : 'FAIL'}`);
process.exitCode = result.ok ? 0 : 1;
} finally {
await browser.close();
}
+3 -2
View File
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 500));
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
if (status && status !== 'CONNECTING') break;
// The label is translated; the machine state is on the control.
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
if (status && status !== 'connecting') break;
}
let diag = null;
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
+58
View File
@@ -84,6 +84,63 @@ try {
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
),
);
const geocoderRequests = [];
const recordGeocoderRequest = (request) => {
const url = new URL(request.url());
if (
url.pathname === '/api/geocode' ||
url.pathname === '/api/google/geocode' ||
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
)
geocoderRequests.push(request.url());
};
page.on('request', recordGeocoderRequest);
await page.$eval('#location-search', (input) => {
input.value = 'geo:39.7392,-104.9903';
input.focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(
() => {
const state =
window.__godsEyeView.styleManager._locationLookup.getState();
return (
state.query === 'geo:39.7392,-104.9903' && state.status === 'found'
);
},
{ timeout: 30000 },
);
await page.waitForFunction(
() => !window.__godsEyeView.viewer.camera._currentFlight,
{ timeout: 10000 },
);
check(
'native geo URI search shows the decimal coordinate readout',
await page.evaluate(() => {
const ui = window.__godsEyeView.styleManager;
return (
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
ui._locationMiniCity.textContent.includes('39.7392° N') &&
ui._locationMiniPoi.textContent === '104.9903° W'
);
}),
);
check(
'geo URI navigation settles near the requested coordinate',
await page.evaluate(() => {
const position = window.__godsEyeView.viewer.camera.positionCartographic;
const degrees = 180 / Math.PI;
return (
Math.abs(position.latitude * degrees - 39.7392) < 0.05 &&
Math.abs(position.longitude * degrees + 104.9903) < 0.05
);
}),
);
check(
'geo URI search makes no geocoder request',
geocoderRequests.length === 0,
);
page.off('request', recordGeocoderRequest);
await page.evaluate(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = [];
@@ -96,6 +153,7 @@ try {
window.__qaSearchRequests.push({ query, options, resolve }),
);
document.getElementById('location-search').value = 'First';
document.getElementById('location-search').focus();
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
+3 -25
View File
@@ -298,11 +298,11 @@ async function main() {
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park high enough that the voice provider row cannot cover the southwest
// handle after the loop below has grown the window.
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 40 - header.y);
await drag(header, 0, 120 - header.y);
}
const limits = {
@@ -346,28 +346,6 @@ async function main() {
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
// Minimum-size clamps do not move the window away from the top edge.
// Drag it down through the real header before proving north-edge growth.
{
const header = await headerPoint();
await drag(header, 0, 140 - header.y);
}
for (const dir of ['n', 'ne']) {
before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const expected = resizeBox(before, dir, dx, dy, limits);
assert.ok(
expected.top <= before.top - 38,
`${dir} has room to grow north`,
);
await drag(handlePoint(before, dir), dx, dy);
after = await readPanel();
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} north ${key}`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: north edges grow from the minimum size');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
+7
View File
@@ -167,6 +167,13 @@ async function main() {
page.on('request', (request) => {
const url = new URL(request.url());
// Radio QA uses keyless maps; an optional Google token is not offered.
if (request.method() === 'GET' && url.origin === APP_ORIGIN
&& url.pathname === '/api/google/tiles-token') {
request.respond({ status: 200, contentType: 'application/json', body: '{}' });
return;
}
// These scenarios exercise Context lifecycle and keyboard ownership, not
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
// CelesTrak outages cannot invalidate an otherwise clean UI run.
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
* Usage:
* node scripts/qa-scene-dialog-file-drop.mjs
*
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
const PAGE = `<!doctype html><html lang="en"><body>
<script type="module">
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
</script></body></html>`;
async function dropOn(client, x, y, file) {
const data = { items: [], files: [file], dragOperationsMask: 1 };
for (const type of ['dragEnter', 'dragOver', 'drop']) {
await client.send('Input.dispatchDragEvent', { type, x, y, data });
}
}
async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1000, height: 800 });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.goto(url);
await page.waitForFunction(() => window.ready === true);
const point = await page.evaluate(() => window.openDialog());
const client = await page.createCDPSession();
await dropOn(client, point.x, point.y, pack);
const names = await page.evaluate(() =>
[...window.filesInput.files].map((file) => file.name),
);
console.log(JSON.stringify({ names, errors }));
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
if (names.length !== 1 || names[0] !== 'pack.json') {
throw new Error(
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
);
}
console.log('OK: a file dropped on the visible control is selected');
} finally {
await browser.close();
server.close();
await rm(dir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+7
View File
@@ -1687,6 +1687,13 @@ async function main() {
await step(
'the header ON/OFF pill switches the layer off and on',
async () => {
// Tiles requested while the credit painted can flip the pill back
// to LOADING, so let coverage settle before reading it.
await waitForCoverage(page);
await settle(
page,
() => document.getElementById('sl-status').textContent === 'ON',
);
assert.equal(
await page.$eval('#sl-status', (node) =>
[
+1 -1
View File
@@ -2182,7 +2182,7 @@ async function runBehaviorLayer() {
?.get('military-awareness')
?.module?.getContextSnapshot?.();
const cohort = snap?.cohorts?.find((c) => c.id === 'flights');
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
return cohort ? cohort.count : null;
});
const windowBlock = r?.contactsWindow || null;
report(
+13 -9
View File
@@ -16,7 +16,6 @@ export const CREDENTIALS = Object.freeze([
{ name: 'GOOGLE_MAPS_SERVER_API_KEY', label: 'Google Places / Street View server', keychain: [] },
{ name: 'CESIUM_ION_TOKEN', label: 'Cesium ion', keychain: [['cesium-ion', 'token']] },
{ name: 'OPENAI_API_KEY', label: 'OpenAI voice', keychain: [['openai-api', 'api-key']] },
{ name: 'GEMINI_API_KEY', label: 'Gemini voice', keychain: [] },
{ name: 'AISSTREAM_API_KEY', label: 'AISStream vessels', keychain: [['aisstream-api', 'api-key']] },
{ name: 'FIRMS_MAP_KEY', label: 'NASA FIRMS fires', keychain: [['firms-map', 'map-key']] },
{ name: 'TOMTOM_API_KEY', label: 'TomTom traffic', keychain: [['tomtom-api', 'api-key']] },
@@ -77,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) {
}
}
/** Return the npm command and spawn mode required by the target platform. */
export function npmProcessSpec(platform = process.platform) {
const windows = platform === 'win32';
return { command: windows ? 'npm.cmd' : 'npm', shell: windows };
/**
* Return the npm command, arguments and spawn mode for the target platform.
* Windows must run npm.cmd through a shell, and Node 24 deprecates passing an
* argument array with `shell: true` (DEP0190), so there the arguments, which
* are fixed npm subcommands, join the command string.
*/
export function npmProcessSpec(args, platform = process.platform) {
if (platform !== 'win32') return { command: 'npm', args, shell: false };
return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true };
}
/** Read one key from Vite's dotenv file ladder without depending on Vite. */
@@ -177,9 +181,9 @@ export function buildCapabilitySummary(
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
flights,
voice: configured('OPENAI_API_KEY') || configured('GEMINI_API_KEY')
voice: configured('OPENAI_API_KEY')
? 'available'
: 'off until an OpenAI or Gemini key is added, or a configured OpenAI provider is selected in Provider Settings',
: 'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation',
@@ -191,8 +195,8 @@ export function buildCapabilitySummary(
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
const node = classifyNodeVersion();
const npm = npmProcessSpec();
const npmResult = spawnSync(npm.command, ['--version'], {
const npm = npmProcessSpec(['--version']);
const npmResult = spawnSync(npm.command, npm.args, {
encoding: 'utf8',
shell: npm.shell,
});
+1
View File
@@ -657,6 +657,7 @@ async function main() {
// SHARE LINK V2 — real reload restores the same logical plane.
// ============================================================
console.log('\nShare Link v2 — tracked aircraft survives a full reload');
await evalPage(() => window.__godsEyeView.styleManager.initialRestorePromise);
const shareTracked = await evalPage(() => (
window.__godsEyeView.dataManager.layers
.get('flights').module.trackById('aaa001', { origin: 'user' })
+4 -4
View File
@@ -82,7 +82,7 @@ export const CORE_PHRASES = Object.freeze([
{ phrase: 'Show me the nearest traffic camera', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'nearest')] },
{ phrase: 'Turn on the camera viewsheds', expect: 'control_cctv', typedArgs: [where('control_cctv', (a) => a.action === 'viewshed' && a.enabled !== false)] },
{ phrase: 'Play a news radio station near Austin', expect: 'control_radio', args: { action: 'select', category: 'news', locationId: 'austin' }, typedArgs: [where('control_radio', (a) => a.action === 'select' && a.category === 'news' && a.locationId === 'austin')] },
{ phrase: 'Turn on the radio', expect: 'control_radio', args: { action: 'enable' }, typedArgs: [where('control_radio', (a) => a.action === 'enable')] },
{ phrase: 'Turn on the radio', expect: 'control_radio', args: { action: 'play' }, typedArgs: [where('control_radio', (a) => a.action === 'play')] },
{ phrase: 'Set the radio volume to thirty percent', expect: 'control_radio', args: { action: 'volume', volumePct: 30 } },
{ phrase: 'Pause the radio', expect: 'control_radio', args: { action: 'pause' }, typedArgs: [where('control_radio', (a) => a.action === 'pause')] },
{ phrase: 'Stop the radio', expect: 'control_radio', args: { action: 'stop' }, typedArgs: [where('control_radio', (a) => a.action === 'stop')] },
@@ -110,7 +110,7 @@ export const CORE_PHRASES = Object.freeze([
{
phrase: 'Go to full planet view and then turn on the radio',
expect: ['zoom_to_globe', 'control_radio'],
argsByTool: { control_radio: { action: 'enable' } },
argsByTool: { control_radio: { action: 'play' } },
},
// — camera verbs (tools #23/#24) —
@@ -507,8 +507,8 @@ export const DIALOGUES = Object.freeze([
{
id: 'radio-then-volume',
turns: ['Turn on the radio', 'Turn it down to twenty percent'],
results: { control_radio: [{ ok: true, enabled: true, action: 'enable', audioState: 'stopped' }] },
first: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'enable')] },
results: { control_radio: [{ ok: true, prepared: true, action: 'play', station: 'KUT 90.5 Austin' }] },
first: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'play')] },
second: { expect: 'control_radio', typedArgs: [where('control_radio', (a) => a.action === 'volume' && a.volumePct === 20)] },
},
{