mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(street-level): point a key-gated layer at Street View; button beside the hint
Review of #966: with a Google Maps key but no Mapillary token, switching Street Level on read KEY REQUIRED / "Needs MAPILLARY_CLIENT_TOKEN" with nothing about Street View, which stayed off (billing), so the layer looked broken. - While the switched-on providers lack their keys, the layer reports the switched-off ones that would draw with a key already set (keyAlternatives: Street View on Google 3D with a Google key). The panel's KEY REQUIRED tooltip, its coverage line and the layer row's tooltip then read "Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key" (keySetupWithAlternatives). Off Google 3D the plain KEY REQUIRED stays. Street View stays off by default. - OPEN STREET VIEW moves beside the coverage hint: at the default panel width the PROVIDERS row has no room for it, so it wrapped onto a line of its own. - The panel gate's corner drag names what lay under the pointer when it fails. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f5c4f3fda3
commit
e823e15025
15 files changed
+669
-138
No files matched your search
@@ -3063,7 +3063,11 @@ The header pill is the layer switch. With one provider its chip is a layer
|
||||
switch too; with several, darkening the last lit chip turns the layer off. A
|
||||
chip without a usable key explains the key instead of lighting (or of
|
||||
switching the layer off as the last lit chip); it still darkens beside a lit
|
||||
one, and the other chips keep switching. The
|
||||
one, and the other chips keep switching. When the switched-on providers lack
|
||||
their keys but a switched-off one would draw with a key already set (no
|
||||
Mapillary token, Street View on Google 3D with a Google key), the layer
|
||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||
line and the layer row's tooltip say to light it. The
|
||||
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
|
||||
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
|
||||
stops when the stack changes. Camera moves go through the application's
|
||||
@@ -3102,7 +3106,7 @@ for other maps, so it draws none: with it on, a click on the map itself
|
||||
Mapillary line under the pointer wins; no tool holding the pointer; camera
|
||||
below 3 km) opens its nearest Google-collected outdoor panorama within 50 m
|
||||
and leaves the camera where it is (`groundClick.js`). Higher up the hint
|
||||
says to zoom in. **OPEN STREET VIEW** in the PROVIDERS row (`#sl-nearest-btn`)
|
||||
says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`)
|
||||
does the same at the view centre, so Street View needs no pointer; it is
|
||||
disabled until the camera is at street zoom. The viewer is Google's `StreetViewPanorama` in its own
|
||||
element in the viewer host, without Google's fullscreen, close and address
|
||||
|
||||
@@ -384,23 +384,33 @@ async function main() {
|
||||
// Grow it from the corner once the restored window holds still, with the
|
||||
// pointer really on the handle (a press beside it selects page text).
|
||||
let chosen = await readPanel();
|
||||
const tried = [];
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
|
||||
attempt++
|
||||
) {
|
||||
await settle();
|
||||
const corner = handlePoint(await readPanel(), 'se');
|
||||
const onHandle = await page.evaluate(
|
||||
({ x, y }) => document.elementFromPoint(x, y)?.dataset?.dir === 'se',
|
||||
corner,
|
||||
const box = await readPanel();
|
||||
const corner = handlePoint(box, 'se');
|
||||
const hit = await page.evaluate(({ x, y }) => {
|
||||
const node = document.elementFromPoint(x, y);
|
||||
return {
|
||||
dir: node?.dataset?.dir || null,
|
||||
target: node
|
||||
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
|
||||
: 'nothing',
|
||||
};
|
||||
}, corner);
|
||||
tried.push(
|
||||
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
|
||||
);
|
||||
if (onHandle) await drag(corner, 0, 200);
|
||||
if (hit.dir === 'se') await drag(corner, 0, 200);
|
||||
chosen = await readPanel();
|
||||
}
|
||||
assert.ok(
|
||||
chosen.height > MIN_SIZE.height + 150,
|
||||
`the window grew to ${chosen.height}px`,
|
||||
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
|
||||
);
|
||||
await setCollapsed(true);
|
||||
const strip = await readPanel();
|
||||
|
||||
@@ -352,6 +352,30 @@ export function keySetupRequirement(id) {
|
||||
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key guidance that also names the switched-off sources the layer could use
|
||||
* with a key that is already set: `[{label, requiresKeyId}]`, e.g. Street
|
||||
* View beside a Mapillary layer with no token.
|
||||
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
*/
|
||||
export function keySetupWithAlternatives(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const entry = KEY_SETUP_KEYS.find(
|
||||
(candidate) => candidate.id === requiresKeyId,
|
||||
);
|
||||
const name = entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = `or ${options.join(', or ')}`;
|
||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
|
||||
+433
-118
@@ -9,6 +9,7 @@ import {
|
||||
isKeySetupExternallyManaged,
|
||||
keySetupStatus,
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
knownKeySetupEnvVars,
|
||||
parseWindowsUserSid,
|
||||
upsertDotenvValues,
|
||||
@@ -28,7 +29,10 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation
|
||||
// panel save has already set its values live on process.env. A recomputed
|
||||
// snapshot would classify the panel's own keys as external (read-only) until
|
||||
// a full process relaunch, so the first evaluation's snapshot must win.
|
||||
const source = readFileSync(new URL('../server/standalone/key-setup.js', import.meta.url), 'utf8');
|
||||
const source = readFileSync(
|
||||
new URL('../server/standalone/key-setup.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const PROVIDER_ENV_AT_BOOT\s*=\s*\(?globalThis\.__GEV_PROVIDER_ENV_AT_BOOT\s*\?\?=\s*Object\.freeze\(/,
|
||||
@@ -36,24 +40,40 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation
|
||||
});
|
||||
|
||||
test('external ownership uses boot provenance even when store and shell values match', () => {
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'same-value',
|
||||
storedValue: 'same-value',
|
||||
wasExternalAtBoot: true,
|
||||
}), true, 'equal bytes cannot turn a shell/Keychain value into a file-owned value');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'file-value',
|
||||
storedValue: 'file-value',
|
||||
}), false, 'a value loaded only from the owned store remains editable');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: 'shell-value',
|
||||
storedValue: 'stale-file-value',
|
||||
}), true, 'a differing live value remains external');
|
||||
assert.equal(isKeySetupExternallyManaged({
|
||||
effectiveValue: '',
|
||||
storedValue: 'stale-file-value',
|
||||
wasExternalAtBoot: true,
|
||||
}), false, 'an absent live credential has no external owner');
|
||||
assert.equal(
|
||||
isKeySetupExternallyManaged({
|
||||
effectiveValue: 'same-value',
|
||||
storedValue: 'same-value',
|
||||
wasExternalAtBoot: true,
|
||||
}),
|
||||
true,
|
||||
'equal bytes cannot turn a shell/Keychain value into a file-owned value',
|
||||
);
|
||||
assert.equal(
|
||||
isKeySetupExternallyManaged({
|
||||
effectiveValue: 'file-value',
|
||||
storedValue: 'file-value',
|
||||
}),
|
||||
false,
|
||||
'a value loaded only from the owned store remains editable',
|
||||
);
|
||||
assert.equal(
|
||||
isKeySetupExternallyManaged({
|
||||
effectiveValue: 'shell-value',
|
||||
storedValue: 'stale-file-value',
|
||||
}),
|
||||
true,
|
||||
'a differing live value remains external',
|
||||
);
|
||||
assert.equal(
|
||||
isKeySetupExternallyManaged({
|
||||
effectiveValue: '',
|
||||
storedValue: 'stale-file-value',
|
||||
wasExternalAtBoot: true,
|
||||
}),
|
||||
false,
|
||||
'an absent live credential has no external owner',
|
||||
);
|
||||
});
|
||||
|
||||
test('the status payload reports presence without any credential material', () => {
|
||||
@@ -63,17 +83,35 @@ test('the status payload reports presence without any credential material', () =
|
||||
// Secret missing: the OpenSky pair must read as NOT set.
|
||||
};
|
||||
const status = keySetupStatus(env);
|
||||
assert.equal(status.total, KEY_SETUP_KEYS.filter((key) => !key.hidden).length);
|
||||
assert.equal(
|
||||
status.total,
|
||||
KEY_SETUP_KEYS.filter((key) => !key.hidden).length,
|
||||
);
|
||||
const google = status.keys.find((key) => key.id === 'google-maps');
|
||||
assert.equal(google.set, true);
|
||||
const opensky = status.keys.find((key) => key.id === 'opensky');
|
||||
assert.equal(opensky.set, false, 'half a credential pair is not configured');
|
||||
const serialized = JSON.stringify(status);
|
||||
assert.ok(!serialized.includes('AIzaSyFakeFakeFakeFake1234'), 'a value leaked into status');
|
||||
assert.ok(!serialized.includes('client-id-abcdef'), 'a value leaked into status');
|
||||
assert.ok(!serialized.includes('1234'), 'a credential suffix leaked into status');
|
||||
assert.ok(!serialized.includes('abcdef'), 'a credential suffix leaked into status');
|
||||
assert.ok(!serialized.includes('tails'), 'status must not expose a credential-tail field');
|
||||
assert.ok(
|
||||
!serialized.includes('AIzaSyFakeFakeFakeFake1234'),
|
||||
'a value leaked into status',
|
||||
);
|
||||
assert.ok(
|
||||
!serialized.includes('client-id-abcdef'),
|
||||
'a value leaked into status',
|
||||
);
|
||||
assert.ok(
|
||||
!serialized.includes('1234'),
|
||||
'a credential suffix leaked into status',
|
||||
);
|
||||
assert.ok(
|
||||
!serialized.includes('abcdef'),
|
||||
'a credential suffix leaked into status',
|
||||
);
|
||||
assert.ok(
|
||||
!serialized.includes('tails'),
|
||||
'status must not expose a credential-tail field',
|
||||
);
|
||||
assert.equal(status.setCount, 1);
|
||||
});
|
||||
|
||||
@@ -84,25 +122,57 @@ test('whitespace-only env values do not count as configured', () => {
|
||||
|
||||
test('subprocess success requires a clean zero exit', () => {
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: null }), true);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 1, signal: null }), false);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), false);
|
||||
assert.equal(commandCompletedSuccessfully({ status: 0, signal: null, error: new Error('spawn failed') }), false);
|
||||
assert.equal(
|
||||
commandCompletedSuccessfully({ status: 1, signal: null }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
commandCompletedSuccessfully({
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: new Error('spawn failed'),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(commandCompletedSuccessfully(null), false);
|
||||
});
|
||||
|
||||
test('Windows owner SID parsing reads only the structured user-SID CSV field', () => {
|
||||
const localUser = 'S-1-5-21-1111111111-2222222222-3333333333-1001';
|
||||
const entraUser = 'S-1-12-1-1111111111-2222222222-3333333333-4444444444';
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), localUser);
|
||||
assert.equal(parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), entraUser);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`),
|
||||
localUser,
|
||||
);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`),
|
||||
entraUser,
|
||||
);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(`"${localUser}","S-1-5-32-545"`),
|
||||
null,
|
||||
'an SID-looking account name must never be mistaken for the token SID',
|
||||
);
|
||||
assert.equal(parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), null, 'broad group SID refused');
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`), null);
|
||||
assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), null, 'unterminated CSV refused');
|
||||
assert.equal(
|
||||
parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'),
|
||||
null,
|
||||
'broad group SID refused',
|
||||
);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(
|
||||
`"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`),
|
||||
null,
|
||||
'unterminated CSV refused',
|
||||
);
|
||||
});
|
||||
|
||||
test('validation accepts every registry env var and only those', () => {
|
||||
@@ -112,8 +182,16 @@ test('validation accepts every registry env var and only those', () => {
|
||||
assert.equal(verdict.ok, true, `${name} should validate`);
|
||||
assert.equal(verdict.updates[name], 'valid-value-123');
|
||||
}
|
||||
assert.equal(validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, false, 'PATH must be refused');
|
||||
assert.equal(validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, false, 'NODE_OPTIONS must be refused');
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ PATH: '/usr/bin' }).ok,
|
||||
false,
|
||||
'PATH must be refused',
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok,
|
||||
false,
|
||||
'NODE_OPTIONS must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
test('validation trims, and refuses empties, newlines, spaces, and oversize values', () => {
|
||||
@@ -122,11 +200,25 @@ test('validation trims, and refuses empties, newlines, spaces, and oversize valu
|
||||
assert.equal(trimmed.updates.OPENAI_API_KEY, 'sk-abc123');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }).ok, false);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, false, 'newline injection');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, false, 'inner space');
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, false, 'non-ASCII');
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1) }).ok,
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok,
|
||||
false,
|
||||
'newline injection',
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok,
|
||||
false,
|
||||
'inner space',
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok,
|
||||
false,
|
||||
'non-ASCII',
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({
|
||||
OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1),
|
||||
}).ok,
|
||||
false,
|
||||
);
|
||||
assert.equal(validateKeySetupUpdates(null).ok, false);
|
||||
@@ -144,13 +236,16 @@ test('upsert replaces the last active assignment in place', () => {
|
||||
'',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { OPENAI_API_KEY: 'new-key' });
|
||||
assert.equal(next, [
|
||||
'# comment stays',
|
||||
'OPENAI_API_KEY=old-one',
|
||||
'PORT=4173',
|
||||
'OPENAI_API_KEY=new-key',
|
||||
'',
|
||||
].join('\n'));
|
||||
assert.equal(
|
||||
next,
|
||||
[
|
||||
'# comment stays',
|
||||
'OPENAI_API_KEY=old-one',
|
||||
'PORT=4173',
|
||||
'OPENAI_API_KEY=new-key',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('upsert uncomments a commented assignment in place, keeping file shape', () => {
|
||||
@@ -161,36 +256,49 @@ test('upsert uncomments a commented assignment in place, keeping file shape', ()
|
||||
'PORT=4173',
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { FIRMS_MAP_KEY: 'firms-123' });
|
||||
assert.equal(next, [
|
||||
'# Optional: NASA FIRMS live active fires.',
|
||||
'FIRMS_MAP_KEY=firms-123',
|
||||
'',
|
||||
'PORT=4173',
|
||||
].join('\n') + '\n');
|
||||
assert.equal(
|
||||
next,
|
||||
[
|
||||
'# Optional: NASA FIRMS live active fires.',
|
||||
'FIRMS_MAP_KEY=firms-123',
|
||||
'',
|
||||
'PORT=4173',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('upsert appends unknown keys under one shared header, once', () => {
|
||||
const first = upsertDotenvValues('PORT=4173\n', { OPENAI_API_KEY: 'sk-1' });
|
||||
assert.equal(first, [
|
||||
'PORT=4173',
|
||||
'',
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
'OPENAI_API_KEY=sk-1',
|
||||
].join('\n') + '\n');
|
||||
assert.equal(
|
||||
first,
|
||||
['PORT=4173', '', KEY_SETUP_APPEND_HEADER, 'OPENAI_API_KEY=sk-1'].join(
|
||||
'\n',
|
||||
) + '\n',
|
||||
);
|
||||
const second = upsertDotenvValues(first, { FIRMS_MAP_KEY: 'f-2' });
|
||||
assert.equal(second, [
|
||||
'PORT=4173',
|
||||
'',
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
'OPENAI_API_KEY=sk-1',
|
||||
'FIRMS_MAP_KEY=f-2',
|
||||
].join('\n') + '\n');
|
||||
assert.equal(second.split(KEY_SETUP_APPEND_HEADER).length, 2, 'header written once');
|
||||
assert.equal(
|
||||
second,
|
||||
[
|
||||
'PORT=4173',
|
||||
'',
|
||||
KEY_SETUP_APPEND_HEADER,
|
||||
'OPENAI_API_KEY=sk-1',
|
||||
'FIRMS_MAP_KEY=f-2',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
assert.equal(
|
||||
second.split(KEY_SETUP_APPEND_HEADER).length,
|
||||
2,
|
||||
'header written once',
|
||||
);
|
||||
});
|
||||
|
||||
test('upsert births a well-formed file from nothing', () => {
|
||||
const next = upsertDotenvValues('', { GOOGLE_MAPS_API_KEY: 'AIza-x' });
|
||||
assert.equal(next, `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`);
|
||||
assert.equal(
|
||||
next,
|
||||
`${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test('upsert handles export-prefixed lines and never touches lookalike keys', () => {
|
||||
@@ -207,15 +315,21 @@ test('upsert handles export-prefixed lines and never touches lookalike keys', ()
|
||||
});
|
||||
|
||||
test('upsert is idempotent for a repeated save', () => {
|
||||
const once = upsertDotenvValues('', { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' });
|
||||
const twice = upsertDotenvValues(once, { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' });
|
||||
const once = upsertDotenvValues('', {
|
||||
OPENAI_API_KEY: 'sk-1',
|
||||
FIRMS_MAP_KEY: 'f-1',
|
||||
});
|
||||
const twice = upsertDotenvValues(once, {
|
||||
OPENAI_API_KEY: 'sk-1',
|
||||
FIRMS_MAP_KEY: 'f-1',
|
||||
});
|
||||
assert.equal(once, twice);
|
||||
});
|
||||
|
||||
test('a real .env.example round-trip: the curated file keeps its shape', () => {
|
||||
// A representative slice of the shipped .env.example.
|
||||
const example = [
|
||||
'# God\'s Eye View — environment variables',
|
||||
"# God's Eye View — environment variables",
|
||||
'GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here',
|
||||
'',
|
||||
'# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.',
|
||||
@@ -232,8 +346,16 @@ test('a real .env.example round-trip: the curated file keeps its shape', () => {
|
||||
const lines = next.split('\n');
|
||||
assert.equal(lines[1], 'GOOGLE_MAPS_API_KEY=AIza-real');
|
||||
assert.equal(lines[4], 'OPENAI_API_KEY=sk-real');
|
||||
assert.equal(lines[5], 'OPENAI_REALTIME_MODEL=gpt-realtime-2', 'sibling key untouched');
|
||||
assert.equal(lines[7], 'TOMTOM_API_KEY=tt-real', 'commented key uncommented in place');
|
||||
assert.equal(
|
||||
lines[5],
|
||||
'OPENAI_REALTIME_MODEL=gpt-realtime-2',
|
||||
'sibling key untouched',
|
||||
);
|
||||
assert.equal(
|
||||
lines[7],
|
||||
'TOMTOM_API_KEY=tt-real',
|
||||
'commented key uncommented in place',
|
||||
);
|
||||
});
|
||||
|
||||
test('the admission gate refuses every non-local shape, one assertion per refusal', async () => {
|
||||
@@ -246,29 +368,111 @@ test('the admission gate refuses every non-local shape, one assertion per refusa
|
||||
contentType: 'application/json',
|
||||
env: {},
|
||||
};
|
||||
assert.equal(admitKeySetupRequest(local).ok, true, 'the honest local request is admitted');
|
||||
assert.equal(admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }).ok, true, 'local GET needs no content type');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: undefined }).ok, false, 'POST without Origin is refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, method: 'GET', origin: undefined, contentType: undefined }).ok, true, 'local GET may omit Origin');
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '::ffff:127.0.0.1', hostHeader: '[::1]:4173', origin: 'http://[::1]:4173' }).ok, true, 'IPv6 loopback forms are local');
|
||||
assert.equal(
|
||||
admitKeySetupRequest(local).ok,
|
||||
true,
|
||||
'the honest local request is admitted',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined })
|
||||
.ok,
|
||||
true,
|
||||
'local GET needs no content type',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: undefined }).ok,
|
||||
false,
|
||||
'POST without Origin is refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
method: 'GET',
|
||||
origin: undefined,
|
||||
contentType: undefined,
|
||||
}).ok,
|
||||
true,
|
||||
'local GET may omit Origin',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
remoteAddress: '::ffff:127.0.0.1',
|
||||
hostHeader: '[::1]:4173',
|
||||
origin: 'http://[::1]:4173',
|
||||
}).ok,
|
||||
true,
|
||||
'IPv6 loopback forms are local',
|
||||
);
|
||||
|
||||
// Tunnel/LAN sharing of any kind removes the surface outright — tunnel
|
||||
// traffic arrives FROM loopback, so no socket check can carry this boundary.
|
||||
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_CLOUDFLARE: 'true' } }).ok, false, 'sharing disables the surface');
|
||||
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({
|
||||
...local,
|
||||
env: { PINOKIO_SHARE_CLOUDFLARE: 'true' },
|
||||
}).ok,
|
||||
false,
|
||||
'sharing disables the surface',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok,
|
||||
false,
|
||||
'LAN sharing disables the surface',
|
||||
);
|
||||
// A LAN peer reaching a wide-bound server.
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok,
|
||||
false,
|
||||
'non-loopback socket refused',
|
||||
);
|
||||
// Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket.
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: '' }).ok, false, 'missing Host refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, false, 'malformed bracketed Host refused');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok,
|
||||
false,
|
||||
'foreign Host refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok,
|
||||
false,
|
||||
'non-localhost hostnames refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, hostHeader: '' }).ok,
|
||||
false,
|
||||
'missing Host refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok,
|
||||
false,
|
||||
'malformed bracketed Host refused',
|
||||
);
|
||||
// A hostile web page POSTing at localhost carries its own Origin.
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, false, 'cross-origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, false, 'unparseable Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, false, 'cross-port Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme Origin refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host Origin refused');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok,
|
||||
false,
|
||||
'cross-origin refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: 'not a url' }).ok,
|
||||
false,
|
||||
'unparseable Origin refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok,
|
||||
false,
|
||||
'cross-port Origin refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok,
|
||||
false,
|
||||
'cross-scheme Origin refused',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok,
|
||||
false,
|
||||
'different loopback host Origin refused',
|
||||
);
|
||||
// A simple-request POST (no JSON content type) is the CSRF write shape.
|
||||
const noJson = admitKeySetupRequest({ ...local, contentType: 'text/plain' });
|
||||
assert.equal(noJson.ok, false, 'non-JSON POST refused');
|
||||
@@ -279,8 +483,16 @@ test('a null value validates as a removal; an empty string still does not', () =
|
||||
const removal = validateKeySetupUpdates({ OPENAI_API_KEY: null });
|
||||
assert.equal(removal.ok, true);
|
||||
assert.equal(removal.updates.OPENAI_API_KEY, null);
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false, 'empty is a mistake, not a removal');
|
||||
assert.equal(validateKeySetupUpdates({ PATH: null }).ok, false, 'removal is registry-bound too');
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok,
|
||||
false,
|
||||
'empty is a mistake, not a removal',
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ PATH: null }).ok,
|
||||
false,
|
||||
'removal is registry-bound too',
|
||||
);
|
||||
});
|
||||
|
||||
test('removal comments the assignment back out, returning the file to template shape', () => {
|
||||
@@ -291,7 +503,11 @@ test('removal comments the assignment back out, returning the file to template s
|
||||
].join('\n');
|
||||
const next = upsertDotenvValues(text, { OPENAI_API_KEY: null });
|
||||
const lines = next.split('\n');
|
||||
assert.equal(lines[1], '# OPENAI_API_KEY=', 'active line commented out, not deleted');
|
||||
assert.equal(
|
||||
lines[1],
|
||||
'# OPENAI_API_KEY=',
|
||||
'active line commented out, not deleted',
|
||||
);
|
||||
assert.equal(lines[2], 'PORT=4173', 'neighbors untouched');
|
||||
// Removing a key with no active assignment changes nothing.
|
||||
assert.equal(upsertDotenvValues(next, { FIRMS_MAP_KEY: null }), next);
|
||||
@@ -303,62 +519,161 @@ test('removal comments the assignment back out, returning the file to template s
|
||||
test('the sharing gate treats a real PINOKIO_SHARE_VAR as sharing, but not the empty/sentinel normal state', async () => {
|
||||
const { admitKeySetupRequest } = await import('./keySetupCore.mjs');
|
||||
const base = {
|
||||
method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json',
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
};
|
||||
// The ordinary launch states: unset, empty, or the explicit disabled sentinel.
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: {} }).ok, true, 'unset SHARE_VAR is normal');
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, true, 'empty SHARE_VAR is normal');
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' } }).ok, true, 'the disabled sentinel is normal');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...base, env: {} }).ok,
|
||||
true,
|
||||
'unset SHARE_VAR is normal',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok,
|
||||
true,
|
||||
'empty SHARE_VAR is normal',
|
||||
);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({
|
||||
...base,
|
||||
env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' },
|
||||
}).ok,
|
||||
true,
|
||||
'the disabled sentinel is normal',
|
||||
);
|
||||
// A real tunnel var disables the surface.
|
||||
assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' } }).ok, false, 'a real share var is sharing');
|
||||
assert.equal(
|
||||
admitKeySetupRequest({
|
||||
...base,
|
||||
env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' },
|
||||
}).ok,
|
||||
false,
|
||||
'a real share var is sharing',
|
||||
);
|
||||
});
|
||||
|
||||
test('the gate refuses proxied requests even from a loopback socket with local headers', async () => {
|
||||
const { admitKeySetupRequest } = await import('./keySetupCore.mjs');
|
||||
const base = {
|
||||
method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173', contentType: 'application/json', env: {},
|
||||
method: 'POST',
|
||||
remoteAddress: '127.0.0.1',
|
||||
hostHeader: 'localhost:4173',
|
||||
origin: 'http://localhost:4173',
|
||||
contentType: 'application/json',
|
||||
env: {},
|
||||
};
|
||||
assert.equal(admitKeySetupRequest(base).ok, true, 'no proxy headers → admitted');
|
||||
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
|
||||
assert.equal(
|
||||
admitKeySetupRequest(base).ok,
|
||||
true,
|
||||
'no proxy headers → admitted',
|
||||
);
|
||||
for (const header of [
|
||||
'x-forwarded-for',
|
||||
'forwarded',
|
||||
'via',
|
||||
'cf-connecting-ip',
|
||||
'cf-ray',
|
||||
'x-real-ip',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-port',
|
||||
'x-forwarded-proto',
|
||||
]) {
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }).ok,
|
||||
admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } })
|
||||
.ok,
|
||||
false,
|
||||
`${header} present → refused`,
|
||||
);
|
||||
}
|
||||
// An empty forwarding header is not a proxy signal.
|
||||
assert.equal(admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
|
||||
assert.equal(
|
||||
admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } })
|
||||
.ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('validation rejects dotenv metacharacters that would round-trip wrong', () => {
|
||||
for (const bad of ['abc#def', 'ab"cd', "ab'cd", 'ab$cd', 'ab\\cd', 'ab`cd']) {
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, false, `${JSON.stringify(bad)} refused`);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok,
|
||||
false,
|
||||
`${JSON.stringify(bad)} refused`,
|
||||
);
|
||||
}
|
||||
// Real key alphabets still pass: base64url, JWT dots, hex, plus/slash.
|
||||
for (const good of ['sk-AbC0-9_x', 'eyJhbGc.eyJzdWI.QWxpY2U', 'a1b2c3d4e5f6', 'AB+cd/ef=']) {
|
||||
assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, true, `${good} accepted`);
|
||||
for (const good of [
|
||||
'sk-AbC0-9_x',
|
||||
'eyJhbGc.eyJzdWI.QWxpY2U',
|
||||
'a1b2c3d4e5f6',
|
||||
'AB+cd/ef=',
|
||||
]) {
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok,
|
||||
true,
|
||||
`${good} accepted`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('server Google key remains supported without appearing in setup or its missing count', () => {
|
||||
const secret = 'server-key-fixture';
|
||||
assert.deepEqual(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), {
|
||||
ok: true, updates: { GOOGLE_MAPS_SERVER_API_KEY: secret },
|
||||
});
|
||||
assert.equal(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, true);
|
||||
assert.deepEqual(
|
||||
validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }),
|
||||
{
|
||||
ok: true,
|
||||
updates: { GOOGLE_MAPS_SERVER_API_KEY: secret },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok,
|
||||
true,
|
||||
);
|
||||
const status = keySetupStatus({ GOOGLE_MAPS_SERVER_API_KEY: secret });
|
||||
assert.deepEqual(status, keySetupStatus({}));
|
||||
assert.equal(status.keys.some((key) => key.id === 'google-maps-server'), false);
|
||||
assert.equal(
|
||||
status.keys.some((key) => key.id === 'google-maps-server'),
|
||||
false,
|
||||
);
|
||||
assert.equal(keySetupRequirement('google-maps-server'), '');
|
||||
assert.equal(status.keys.find((key) => key.id === 'google-maps').title, 'GOOGLE MAPS');
|
||||
const allVisibleConfigured = Object.fromEntries(status.keys.flatMap((key) =>
|
||||
key.envVars.map((name) => [name, 'configured-fixture']),
|
||||
));
|
||||
assert.equal(
|
||||
status.keys.find((key) => key.id === 'google-maps').title,
|
||||
'GOOGLE MAPS',
|
||||
);
|
||||
const allVisibleConfigured = Object.fromEntries(
|
||||
status.keys.flatMap((key) =>
|
||||
key.envVars.map((name) => [name, 'configured-fixture']),
|
||||
),
|
||||
);
|
||||
const complete = keySetupStatus(allVisibleConfigured);
|
||||
assert.equal(complete.setCount, complete.total, 'an absent server key must not leave setup incomplete');
|
||||
assert.deepEqual(complete, keySetupStatus({ ...allVisibleConfigured, GOOGLE_MAPS_SERVER_API_KEY: secret }));
|
||||
assert.equal(
|
||||
complete.setCount,
|
||||
complete.total,
|
||||
'an absent server key must not leave setup incomplete',
|
||||
);
|
||||
assert.deepEqual(
|
||||
complete,
|
||||
keySetupStatus({
|
||||
...allVisibleConfigured,
|
||||
GOOGLE_MAPS_SERVER_API_KEY: secret,
|
||||
}),
|
||||
);
|
||||
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
|
||||
assert.ok(!JSON.stringify(status).includes(secret));
|
||||
});
|
||||
|
||||
test('key guidance can offer another source that works with a key already set', () => {
|
||||
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
|
||||
assert.equal(
|
||||
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(
|
||||
keySetupWithAlternatives('', [streetView]),
|
||||
'Light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import { createViewerHost } from './viewerHost.js';
|
||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import {
|
||||
composeUIState,
|
||||
keyAlternatives,
|
||||
summarizeCoverage,
|
||||
} from './uiState.js';
|
||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
@@ -501,7 +505,8 @@ export function createStreetLevelLayer({
|
||||
|
||||
getStats() {
|
||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||
const coverage = summarizeCoverage(providerSnapshots());
|
||||
const snapshots = providerSnapshots();
|
||||
const coverage = summarizeCoverage(snapshots);
|
||||
let loadingLabel = '';
|
||||
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||
@@ -512,6 +517,8 @@ export function createStreetLevelLayer({
|
||||
count: coverage.count,
|
||||
loading: coverage.loading,
|
||||
keyRequired: coverage.keyRequired,
|
||||
/** Switched-off sources that would draw with a key already set. */
|
||||
keyAlternatives: keyAlternatives(snapshots),
|
||||
error: coverage.keyRequired
|
||||
? coverage.keyRejected
|
||||
? coverage.error || keyLabel
|
||||
|
||||
@@ -484,7 +484,10 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
|
||||
});
|
||||
|
||||
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
|
||||
async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
|
||||
async function withStreetView(
|
||||
t,
|
||||
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
|
||||
) {
|
||||
const { createGoogleProvider } = await import('./providers/google/index.js');
|
||||
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
|
||||
await import('../../testSupport/googleStreetViewFakes.mjs');
|
||||
@@ -502,6 +505,14 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
|
||||
},
|
||||
});
|
||||
mapillary.requiresKeyId = 'mapillary';
|
||||
if (mapillaryKeyless) {
|
||||
mapillary.stats.keyRequired = true;
|
||||
const create = mapillary.create;
|
||||
mapillary.create = (context) => ({
|
||||
...create(context),
|
||||
status: async () => ({ configured: false }),
|
||||
});
|
||||
}
|
||||
const loader = fakeMapsLoader(fake.library);
|
||||
const google = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
@@ -637,3 +648,20 @@ test('the button’s “centre of the view” is the ground under the middle of
|
||||
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
|
||||
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
|
||||
});
|
||||
|
||||
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
|
||||
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
|
||||
await settle();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.keyRequired, true);
|
||||
assert.deepEqual(stats.keyAlternatives, [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
]);
|
||||
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
|
||||
stack.switchTo('esri-imagery');
|
||||
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
|
||||
// Lit, Street View draws: the layer is no longer key-gated.
|
||||
stack.switchTo('photoreal');
|
||||
layer.setProviderEnabled('google', true);
|
||||
assert.equal(layer.getStats().keyRequired, false);
|
||||
});
|
||||
@@ -38,6 +38,20 @@ export function summarizeCoverage(providers) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switched-off providers that would draw with a key already set, offered
|
||||
* while the switched-on ones lack theirs (Street View beside a Mapillary
|
||||
* layer with no token).
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
|
||||
*/
|
||||
export function keyAlternatives(providers) {
|
||||
if (!summarizeCoverage(providers).keyRequired) return [];
|
||||
return providers
|
||||
.filter((p) => !p.on && p.configured === true && p.keyRequired !== true)
|
||||
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source, then the shared selection colour.
|
||||
@@ -67,6 +81,7 @@ export function composeUIState({
|
||||
/** The camera is at street zoom: a ground-click provider can open here. */
|
||||
groundClickReady: groundClickReady === true,
|
||||
keyRequired,
|
||||
keyAlternatives: keyAlternatives(providers),
|
||||
keyRejected,
|
||||
filter: { ...filter },
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
|
||||
@@ -168,3 +168,27 @@ test('every switched-on provider’s hint is kept, once each', async () => {
|
||||
'zoom in for sequences · click a street to open Street View',
|
||||
);
|
||||
});
|
||||
|
||||
test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => {
|
||||
const { keyAlternatives } = await import('./uiState.js');
|
||||
const mapillary = providerSnapshot({ keyRequired: true, configured: false });
|
||||
const streetView = providerSnapshot({
|
||||
id: 'google',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
});
|
||||
assert.deepEqual(keyAlternatives([mapillary, streetView]), [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
]);
|
||||
// Nothing to offer: Mapillary has its key, or Street View has none either.
|
||||
assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []);
|
||||
assert.deepEqual(
|
||||
keyAlternatives([
|
||||
mapillary,
|
||||
{ ...streetView, configured: false, keyRequired: true },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
|
||||
);
|
||||
assert.equal(layerKeyRequirementTooltip(ungated), '');
|
||||
});
|
||||
|
||||
test('a key-gated layer with another source ready says how to use it', () => {
|
||||
const text = layerKeyRequirementTooltip({
|
||||
requiresKeyId: 'mapillary',
|
||||
stats: {
|
||||
keyRequired: true,
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
text,
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
@@ -100,7 +103,11 @@ function panelLabel(layer) {
|
||||
export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
// A layer with several sources also names the ones it could use instead.
|
||||
return keySetupWithAlternatives(
|
||||
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
|
||||
layer.stats.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
|
||||
@@ -305,7 +305,6 @@ function panelDom() {
|
||||
const settings = add(main, 'div', { classes: ['sl-settings'] });
|
||||
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text'])
|
||||
add(settings, 'div', { id });
|
||||
add(settings, 'button', { id: 'sl-nearest-btn' });
|
||||
// A missing key gates the filters only; the chips stay outside the gate.
|
||||
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
@@ -313,7 +312,9 @@ function panelDom() {
|
||||
add(controls, 'input', { id: 'sl-since' });
|
||||
add(controls, 'output', { id: 'sl-since-label' });
|
||||
add(settings, 'ul', { id: 'sl-legend' });
|
||||
add(settings, 'div', { id: 'sl-coverage-meta' });
|
||||
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
add(metaRow, 'button', { id: 'sl-nearest-btn' });
|
||||
// The globe's canvas is focusable too (tabindex=0).
|
||||
const globe = add(
|
||||
add(document.body, 'div', { id: 'cesiumContainer' }),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -54,13 +57,33 @@ function formatDate(ms, precision = 'day') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the key-gated layer needs: each switched-on provider's key, and the
|
||||
* switched-off ones that would draw with a key already set.
|
||||
*/
|
||||
function keyGuidance(state) {
|
||||
const needs = (state.providers || [])
|
||||
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
|
||||
.map((p) => keySetupRequirement(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return keySetupWithAlternatives(
|
||||
[...new Set(needs)].join('; '),
|
||||
state.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
function presentStatus(state) {
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
if (state.keyRejected)
|
||||
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
|
||||
if (state.keyRequired)
|
||||
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
|
||||
return {
|
||||
text: 'KEY REQUIRED',
|
||||
tone: 'warn',
|
||||
pressed,
|
||||
title: keyGuidance(state) || title,
|
||||
};
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
@@ -162,6 +185,9 @@ function presentNearest(state) {
|
||||
|
||||
function presentMeta(state) {
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
// Key-gated with another source ready: say how to use it.
|
||||
if (state.keyRequired && state.keyAlternatives?.length)
|
||||
return keyGuidance(state);
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
|
||||
@@ -483,3 +483,42 @@ test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom'
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [
|
||||
provider({ keyRequired: true, configured: false }),
|
||||
provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
}),
|
||||
],
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const guidance =
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.title, guidance);
|
||||
assert.equal(view.meta, guidance);
|
||||
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
|
||||
const plain = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [provider({ keyRequired: true, configured: false })],
|
||||
keyAlternatives: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||||
assert.notEqual(plain.meta, guidance);
|
||||
});
|
||||
@@ -183,8 +183,20 @@ button.sl-status:focus-visible {
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
|
||||
.sl-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-meta-row .sl-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sl-nearest-btn {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sl-nearest-btn[hidden] {
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
||||
<span class="cctv-summary-label">PROVIDERS</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
@@ -134,7 +133,11 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user