mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/alpr-visual-treatment
# Conflicts: # scripts/package-boundaries.json
This commit is contained in:
commit
fdf030cbc9
56 files changed
+10029
-47
No files matched your search
@@ -16,7 +16,8 @@ HOST="${HOST:-localhost}"
|
||||
# including Kitchener-area highways), and Fintraffic Finland weathercams
|
||||
# (~2,260 live presets). Caps keep the densest cores per pack; override
|
||||
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
|
||||
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0 and CCTV_FINTRAFFIC_ENABLED=0.
|
||||
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0, CCTV_FINTRAFFIC_ENABLED=0 and
|
||||
# CCTV_CALGARY_ENABLED=0 (Open Calgary, ~215 live upstream).
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
@@ -41,6 +42,8 @@ CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
|
||||
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
|
||||
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
@@ -306,8 +309,8 @@ case "${HOST}" in
|
||||
esac
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
|
||||
echo "If panels are still missing, run this once in browser console:"
|
||||
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
|
||||
echo "The CCTV panel starts collapsed; open it from its header, or in browser console:"
|
||||
echo "localStorage.setItem('godsEyeView.v6.panelCollapsed.cctv-panel', '0'); location.reload();"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
@@ -402,6 +405,8 @@ put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
|
||||
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
|
||||
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
|
||||
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
|
||||
put_env CCTV_CALGARY_ENABLED "${CCTV_CALGARY_ENABLED}"
|
||||
put_env CCTV_CALGARY_MAX_SOURCES "${CCTV_CALGARY_MAX_SOURCES}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
|
||||
@@ -10,11 +10,18 @@
|
||||
"src/tooling/fixtureRoot.mjs",
|
||||
"src/tooling/fixtureRoot.test.mjs",
|
||||
"src/tooling/format.test.mjs",
|
||||
"src/tooling/panelStorageDocs.test.mjs",
|
||||
"src/tooling/packageBoundaries.test.mjs",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/infrastructure.test.mjs",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawMode.test.mjs",
|
||||
"src/annotations/drawTool.js",
|
||||
"src/annotations/drawTool.test.mjs",
|
||||
"src/data/inputOwnership.test.mjs",
|
||||
"docs/CODE-BOUNDARIES.md",
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
@@ -155,6 +162,7 @@
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js",
|
||||
"scripts/qa-visual-effects.mjs",
|
||||
"scripts/qa-draw-tool.mjs",
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/ui/mapSourceControls.test.mjs",
|
||||
@@ -467,6 +475,7 @@
|
||||
"src/data/cctvFootprint.test.mjs",
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
@@ -615,5 +624,10 @@
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/records.test.mjs",
|
||||
"src/layers/vessels/ingestion.test.mjs",
|
||||
"src/cityPresets.test.mjs"
|
||||
"src/cityPresets.test.mjs",
|
||||
"server/providers/cctv/range.js",
|
||||
"src/data/cctvMediaRange.test.mjs",
|
||||
"src/materialSymbolsSubset.test.mjs",
|
||||
"scripts/pinokio-update.mjs",
|
||||
"src/pinokioUpdatePreview.test.mjs"
|
||||
]
|
||||
@@ -8,7 +8,8 @@
|
||||
"modules": [
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -152,7 +153,9 @@
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
"server/providers/cctv/range.js",
|
||||
"server/providers/cctv/sources.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/places/google-key.js",
|
||||
@@ -626,7 +629,8 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js"
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -660,7 +664,8 @@
|
||||
"src/layers/military/tracking.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js"
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -683,7 +688,8 @@
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/index.js"
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -706,7 +712,8 @@
|
||||
"src/data/vesselLabels.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js"
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -737,7 +744,8 @@
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
"src/layers/firms/viewport.js"
|
||||
"src/layers/firms/viewport.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -759,7 +767,8 @@
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/state.js",
|
||||
"src/layers/satellites/testing.js",
|
||||
"src/layers/satellites/tracking.js"
|
||||
"src/layers/satellites/tracking.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -784,7 +793,8 @@
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/source.js",
|
||||
"src/layers/launches/state.js",
|
||||
"src/layers/launches/testing.js"
|
||||
"src/layers/launches/testing.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -803,7 +813,8 @@
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/viewport.js"
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -877,7 +888,8 @@
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/viewport.js"
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -914,7 +926,8 @@
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
"src/data/cctvFootprint.js"
|
||||
"src/data/cctvFootprint.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -927,7 +940,8 @@
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/source.js"
|
||||
"src/layers/alpr/source.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -959,7 +973,8 @@
|
||||
"src/layers/radio/tuning.js",
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -975,7 +990,8 @@
|
||||
"src/layers/submarineCables/policy.js",
|
||||
"src/layers/submarineCables/rendering.js",
|
||||
"src/layers/submarineCables/state.js",
|
||||
"src/layers/submarineCables/surface.js"
|
||||
"src/layers/submarineCables/surface.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1559,7 +1575,10 @@
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js"
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawTool.js"
|
||||
],
|
||||
"external": [
|
||||
"@mapbox/vector-tile",
|
||||
@@ -1893,7 +1912,8 @@
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js"
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
|
||||
+237
-3
@@ -1,5 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
import { installPinokioDependencies, runChecked } from './pinokio-install.mjs';
|
||||
/**
|
||||
* Pinokio "Update": disclose what is about to land, then fast-forward and reinstall.
|
||||
*
|
||||
* Update is a fixed menu item aimed at people who only want to run the app, and
|
||||
* it executes install scripts from whatever the configured remote serves. It
|
||||
* cannot ask for confirmation — Pinokio drives it non-interactively — so the
|
||||
* safety it can offer is disclosure: fetch once, print the remote it fetched
|
||||
* from plus the incoming commits and their diffstat, and then apply that exact
|
||||
* revision. Someone who sees an unfamiliar remote or an unexpected set of
|
||||
* commits can close the window before any install script runs.
|
||||
*
|
||||
* @module scripts/pinokio-update
|
||||
*/
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
installPinokioDependencies,
|
||||
isDirectInvocation,
|
||||
runChecked,
|
||||
} from './pinokio-install.mjs';
|
||||
|
||||
runChecked('git', ['pull', '--ff-only']);
|
||||
installPinokioDependencies();
|
||||
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||
|
||||
/**
|
||||
* Read a git value.
|
||||
*
|
||||
* @param {string[]} args - git arguments.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cwd] - Repository to read.
|
||||
* @returns {string|null} Trimmed stdout, `''` when git succeeded with no
|
||||
* output, or null when git failed (no upstream, detached HEAD, not a
|
||||
* checkout). Callers must keep those two apart: an empty range and an
|
||||
* unreadable one mean very different things to someone about to install.
|
||||
*/
|
||||
export function readGit(args, { cwd = ROOT } = {}) {
|
||||
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credentials from a remote URL before it is printed.
|
||||
*
|
||||
* A remote can carry a password or an access token in its userinfo
|
||||
* (`https://token@host/org/repo.git`) or in a query parameter
|
||||
* (`?access_token=…`). The point of printing the remote is to show WHERE the
|
||||
* update comes from, which the host and path already say — so the userinfo is
|
||||
* replaced and the query string and fragment are dropped whole, rather than
|
||||
* matched against a list of parameter names that would miss the next one.
|
||||
*
|
||||
* A secret spelled as an ordinary path segment is not detected; nothing here
|
||||
* can tell one of those from a repository name.
|
||||
*
|
||||
* @param {string|null|undefined} url - Remote URL as git reports it.
|
||||
* @returns {string|null} Printable URL, or null when there is nothing to print.
|
||||
*/
|
||||
export function redactRemoteUrl(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
// Not a URL: scp-style `git@host:org/repo.git`, or a local path. Neither
|
||||
// has a userinfo field to hide.
|
||||
return url;
|
||||
}
|
||||
const hadUserinfo = Boolean(parsed.username || parsed.password);
|
||||
const hadQuery = Boolean(parsed.search || parsed.hash);
|
||||
if (!hadUserinfo && !hadQuery) return url;
|
||||
parsed.password = '';
|
||||
if (hadUserinfo) parsed.username = '***';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the remote, the commits about to be applied, and their diffstat.
|
||||
*
|
||||
* @param {object} [io] - Injection seam for tests.
|
||||
* @param {string} [io.cwd] - Repository to inspect.
|
||||
* @param {(line: string) => void} [io.log] - Normal output.
|
||||
* @param {(line: string) => void} [io.warn] - Degraded-path output.
|
||||
* @param {(remote: string) => void} [io.fetchRemote] - Fetch step; throws or
|
||||
* exits on failure, because a report built on a stale remote ref describes
|
||||
* the wrong changes.
|
||||
* @param {(args: string[]) => (string|null)} [io.read] - git reader.
|
||||
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}}
|
||||
* `apply` is the exact revision that was disclosed and must now be applied.
|
||||
* `fallback` asks the caller to run the plain pull so git itself reports
|
||||
* whatever went wrong, and is only ever set before anything has been fetched.
|
||||
* `stopped` means the update gave up: nothing can be shown, so nothing is
|
||||
* applied.
|
||||
*/
|
||||
export function reportIncomingChanges(io = {}) {
|
||||
const {
|
||||
cwd = ROOT,
|
||||
log = (line) => console.log(line),
|
||||
warn = (line) => console.warn(line),
|
||||
fetchRemote = (remote) => runChecked('git', ['fetch', '--quiet', remote]),
|
||||
read = (args) => readGit(args, { cwd }),
|
||||
} = io;
|
||||
|
||||
const upstream = read([
|
||||
'rev-parse',
|
||||
'--abbrev-ref',
|
||||
'--symbolic-full-name',
|
||||
'@{u}',
|
||||
]);
|
||||
// The full ref name is what later reads resolve, so a remote whose own name
|
||||
// contains a slash (`team/origin`) cannot be mistaken for a path segment.
|
||||
const upstreamRef = read(['rev-parse', '--symbolic-full-name', '@{u}']);
|
||||
const branch = read(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
const remote =
|
||||
branch && branch !== 'HEAD'
|
||||
? read(['config', '--get', `branch.${branch}.remote`])
|
||||
: null;
|
||||
if (!upstream || !upstreamRef || !remote) {
|
||||
warn('[update] No upstream branch is configured — cannot preview changes.');
|
||||
warn('[update] Continuing; `git pull --ff-only` will report the problem.');
|
||||
return { apply: null, fallback: true };
|
||||
}
|
||||
|
||||
log(`[update] Tracking ${upstream}`);
|
||||
if (remote === '.') {
|
||||
// The upstream is another branch in this same checkout; there is nothing
|
||||
// to fetch and no remote URL to disclose.
|
||||
log('[update] Upstream is a local branch — nothing to fetch.');
|
||||
} else {
|
||||
const url = redactRemoteUrl(read(['remote', 'get-url', remote]));
|
||||
log(`[update] Fetching from ${url || remote}`);
|
||||
// Fetch once, here. Everything below describes and then applies the
|
||||
// revision this fetch brought in; a second fetch inside `git pull` could
|
||||
// apply something newer than what was printed.
|
||||
fetchRemote(remote);
|
||||
}
|
||||
|
||||
const target = read(['rev-parse', upstreamRef]);
|
||||
const head = read(['rev-parse', 'HEAD']);
|
||||
if (!target || !head) {
|
||||
// Stop. Falling through to `git pull` here would fetch a second time and
|
||||
// could install a revision this run never resolved, let alone disclosed.
|
||||
warn(`[update] Could not resolve ${upstream} after fetching.`);
|
||||
warn(
|
||||
'[update] Stopping: nothing is applied, because nothing can be shown.',
|
||||
);
|
||||
return { apply: null, fallback: false, stopped: true };
|
||||
}
|
||||
if (target === head) {
|
||||
log('[update] Already up to date — reinstalling dependencies only.');
|
||||
return { apply: null, fallback: false };
|
||||
}
|
||||
|
||||
const commits = read([
|
||||
'log',
|
||||
'--oneline',
|
||||
'--no-decorate',
|
||||
`${head}..${target}`,
|
||||
]);
|
||||
if (commits === null) {
|
||||
// A read that failed is not an empty range. Saying "up to date" here would
|
||||
// tell someone nothing is arriving while a revision is about to be applied.
|
||||
warn(`[update] Could not list the commits in ${head}..${target}.`);
|
||||
warn(
|
||||
`[update] Applying ${target} anyway; it is the revision that was fetched.`,
|
||||
);
|
||||
return { apply: target, fallback: false };
|
||||
}
|
||||
|
||||
const lines = commits.length > 0 ? commits.split('\n') : [];
|
||||
if (lines.length > 0) {
|
||||
log(`\n[update] ${lines.length} incoming commit(s):`);
|
||||
for (const line of lines) log(` ${line}`);
|
||||
} else {
|
||||
log(
|
||||
`\n[update] ${upstream} is at ${target}, which is not ahead of this checkout.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stat = read(['diff', '--stat', `${head}..${target}`]);
|
||||
if (stat === null) {
|
||||
warn(`[update] Could not read the diffstat for ${head}..${target}.`);
|
||||
} else if (stat) {
|
||||
log('\n[update] Files affected:');
|
||||
for (const line of stat.split('\n')) log(` ${line}`);
|
||||
}
|
||||
log(`\n[update] Applying ${target}, then reinstalling dependencies.\n`);
|
||||
return { apply: target, fallback: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disclose, then apply exactly what was disclosed.
|
||||
*
|
||||
* @param {object} [io] - Same seam as {@link reportIncomingChanges}, plus
|
||||
* `apply`, which runs one git command and must fail loudly.
|
||||
* @returns {{apply: string|null, fallback: boolean}} What the report decided.
|
||||
*/
|
||||
export function updateFromRemote(io = {}) {
|
||||
const { apply = (args) => runChecked('git', args) } = io;
|
||||
const plan = reportIncomingChanges(io);
|
||||
// `merge --ff-only <revision>` applies the object the report named. A second
|
||||
// `pull` would fetch again and could land a different one.
|
||||
if (plan.apply) apply(['merge', '--ff-only', plan.apply]);
|
||||
else if (plan.fallback) apply(['pull', '--ff-only']);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* What running this script does: disclose, apply, install.
|
||||
*
|
||||
* @param {object} [io] - The seam above, plus `install` and `fail`.
|
||||
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}} The plan.
|
||||
*/
|
||||
export function runPinokioUpdate(io = {}) {
|
||||
const {
|
||||
install = installPinokioDependencies,
|
||||
fail = (code) => process.exit(code),
|
||||
} = io;
|
||||
const plan = updateFromRemote(io);
|
||||
if (plan.stopped) {
|
||||
// Nothing could be shown, so nothing was applied. Reinstalling now would
|
||||
// run install scripts under the banner of an update that did not happen,
|
||||
// and report success for it.
|
||||
fail(1);
|
||||
return plan;
|
||||
}
|
||||
install();
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Guarded like pinokio-start.mjs so the report above can be exercised without
|
||||
// pulling and reinstalling as an import side effect.
|
||||
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
|
||||
runPinokioUpdate();
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
|
||||
const SHOT_PREFIX = process.env.QA_SHOT_PREFIX || '';
|
||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||
// A dense, recognisable grid: neighbourhood blocks to enclose and a straight
|
||||
// road to trace along.
|
||||
const VIEW = {
|
||||
lon: -122.4194,
|
||||
lat: 37.7749,
|
||||
height: 900,
|
||||
heading: 0,
|
||||
pitch: -85,
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !HEADFUL,
|
||||
...(HEADFUL
|
||||
? {
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
}
|
||||
: {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
...(HEADFUL ? ['--window-size=1280,860'] : []),
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
let failures = 0;
|
||||
function check(name, passed, detail = '') {
|
||||
console.log(
|
||||
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||
);
|
||||
if (!passed) failures++;
|
||||
}
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
const shot = (name) =>
|
||||
page.screenshot({
|
||||
path: path.join(SHOT_DIR, `${SHOT_PREFIX}${name}.jpg`),
|
||||
type: 'jpeg',
|
||||
quality: 82,
|
||||
});
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** A clip of the globe canvas, clear of the panels on every side. */
|
||||
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
|
||||
|
||||
/**
|
||||
* How many amber pixels the clip contains. The drawn area's stroke and fill are
|
||||
* the only amber in this scene, so the count going UP after a shape is finished
|
||||
* is the shape actually painting — which is what CESIUM_3D_TILE-only
|
||||
* classification failed to do on a keyless boot while still reporting a mark on
|
||||
* the board. Decoding happens in the page because Node here has no image
|
||||
* decoder.
|
||||
*/
|
||||
async function amberPixels() {
|
||||
const shot64 = await page.screenshot({
|
||||
clip: CANVAS_CLIP,
|
||||
type: 'png',
|
||||
encoding: 'base64',
|
||||
});
|
||||
return page.evaluate(async (base64) => {
|
||||
const bitmap = await createImageBitmap(
|
||||
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
|
||||
);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
let amber = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
// Warm and saturated: the amber mark (#ffb547) blended over imagery.
|
||||
if (r > 150 && g > 90 && g < 220 && b < 130 && r - b > 70 && r - g > 20)
|
||||
amber += 1;
|
||||
}
|
||||
return amber;
|
||||
}, shot64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press Enter as the person drawing does — with the map focused, not a button.
|
||||
* Enter on a focused control means "press this control", so the tool ignores it
|
||||
* there; the harness has to move focus the way a user would before using the
|
||||
* finish key.
|
||||
*/
|
||||
async function pressFinish() {
|
||||
await page.evaluate(() => document.activeElement?.blur?.());
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
/** One click on the globe canvas, the way a person makes one. */
|
||||
async function clickWorld(x, y, options = {}) {
|
||||
await page.mouse.click(x, y, options);
|
||||
await wait(options.settle ?? 140);
|
||||
}
|
||||
|
||||
const drawState = () =>
|
||||
page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
return {
|
||||
...tool.diagnostics(),
|
||||
shape: tool.shape,
|
||||
vertices: tool.session?.vertices?.length ?? null,
|
||||
hint: document.getElementById('draw-hint')?.textContent || '',
|
||||
marks: window.__gevAnnotations.count(),
|
||||
};
|
||||
});
|
||||
|
||||
const marks = () =>
|
||||
page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((mark) => ({
|
||||
type: mark.type,
|
||||
label: mark.label,
|
||||
color: mark.color,
|
||||
source: mark.source ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Wait until the board has settled at `expected` marks (annotate is async). */
|
||||
async function waitForMarks(expected, timeout = 15_000) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(want) => window.__gevAnnotations.count() === want,
|
||||
{ timeout },
|
||||
expected,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevDrawTool &&
|
||||
window.__gevAnnotations &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (view.heading * Math.PI) / 180,
|
||||
pitch: (view.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, VIEW);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let index = 0; index < primitives.length; index++) {
|
||||
const primitive = primitives.get(index);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
toggle?.getAttribute('aria-pressed') === 'false' &&
|
||||
!document.getElementById('draw-mode-row')?.classList.contains('visible')
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// The viewer's OWN click actions, captured before the tool ever runs. Both
|
||||
// must be absent while drawing and back — as the SAME functions — after.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
});
|
||||
const stockActions = () =>
|
||||
page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
const live = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
return {
|
||||
clickPresent: Boolean(live.click),
|
||||
doublePresent: Boolean(live.double),
|
||||
clickIsOriginal: live.click === window.__qaStock.click,
|
||||
doubleIsOriginal: live.double === window.__qaStock.double,
|
||||
hadClick: Boolean(window.__qaStock.click),
|
||||
hadDouble: Boolean(window.__qaStock.double),
|
||||
};
|
||||
});
|
||||
|
||||
// ── enable / disable cycles leave nothing behind ────────────────────────
|
||||
const before = await drawState();
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
await page.click('#draw-toggle');
|
||||
await wait(120);
|
||||
await page.click('#draw-toggle');
|
||||
await wait(120);
|
||||
}
|
||||
const after = await drawState();
|
||||
check(
|
||||
'three enable/disable cycles add no listener, handler or preview data source',
|
||||
after.domListeners === before.domListeners &&
|
||||
after.previewDataSources === 1 &&
|
||||
before.previewDataSources === 1 &&
|
||||
after.sceneHandler === false &&
|
||||
after.pointerOwner === null &&
|
||||
after.stockDoubleClick === before.stockDoubleClick,
|
||||
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
await page.click('#draw-toggle');
|
||||
const duringDraw = await stockActions();
|
||||
check(
|
||||
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
|
||||
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
|
||||
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
|
||||
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
|
||||
);
|
||||
await page.click('#draw-toggle');
|
||||
const afterDraw = await stockActions();
|
||||
check(
|
||||
'and both are restored as the SAME functions, not replacements',
|
||||
afterDraw.clickPresent === duringDraw.hadClick &&
|
||||
afterDraw.doublePresent === duringDraw.hadDouble &&
|
||||
afterDraw.clickIsOriginal &&
|
||||
afterDraw.doubleIsOriginal,
|
||||
JSON.stringify(afterDraw),
|
||||
);
|
||||
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="pin"]');
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Pin one');
|
||||
await clickWorld(520, 330);
|
||||
check(
|
||||
'the pointer is claimed while a session is open',
|
||||
(await drawState()).pointerOwner === 'draw',
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a pin placed by mouse and finished with Enter lands on the board',
|
||||
await waitForMarks(1),
|
||||
);
|
||||
|
||||
// ── a line along a road, with Backspace ─────────────────────────────────
|
||||
await page.click('.pp-mode-btn[data-shape="line"]');
|
||||
await clickWorld(360, 300);
|
||||
await clickWorld(470, 380);
|
||||
await clickWorld(600, 470);
|
||||
const beforeBackspace = (await drawState()).vertices;
|
||||
await page.keyboard.press('Backspace');
|
||||
await wait(120);
|
||||
const afterBackspace = (await drawState()).vertices;
|
||||
check(
|
||||
'Backspace removes the last vertex only',
|
||||
beforeBackspace === 3 && afterBackspace === 2,
|
||||
`${beforeBackspace} → ${afterBackspace}`,
|
||||
);
|
||||
await clickWorld(640, 500);
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Market St');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a line finished from the label field lands on the board',
|
||||
await waitForMarks(2),
|
||||
);
|
||||
const lineMark = (await marks()).find((mark) =>
|
||||
mark.label?.startsWith('Market St'),
|
||||
);
|
||||
check(
|
||||
'a drawn line reports its length and never a travel time',
|
||||
Boolean(lineMark) &&
|
||||
/\d/.test(lineMark.label) &&
|
||||
!/min|walk|drive/i.test(lineMark.label),
|
||||
lineMark?.label,
|
||||
);
|
||||
await shot('02-line-along-a-road');
|
||||
|
||||
// ── an area, coloured and labelled, finished with a double-click ────────
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await page.select('#draw-color-select', 'amber');
|
||||
const amberBefore = await amberPixels();
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await clickWorld(400, 280);
|
||||
await clickWorld(700, 290);
|
||||
await clickWorld(720, 520);
|
||||
await clickWorld(410, 500);
|
||||
const areaVertices = (await drawState()).vertices;
|
||||
check(
|
||||
'four clicks on the globe are four vertices',
|
||||
areaVertices === 4,
|
||||
`${areaVertices} vertices`,
|
||||
);
|
||||
await page.mouse.click(410, 500, { count: 2 });
|
||||
const areaLanded = await waitForMarks(3);
|
||||
await wait(900);
|
||||
const afterDoubleClick = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
check('an area finished by double-click lands on the board', areaLanded);
|
||||
check(
|
||||
'a double-click finishes the shape exactly once',
|
||||
afterDoubleClick === 3,
|
||||
`${afterDoubleClick} marks`,
|
||||
);
|
||||
const areaMark = (await marks()).find(
|
||||
(mark) => mark.label === 'Civic Center block',
|
||||
);
|
||||
check(
|
||||
'the label and colour chosen in the panel are what is drawn',
|
||||
areaMark?.color === 'amber',
|
||||
JSON.stringify(areaMark),
|
||||
);
|
||||
await wait(1200);
|
||||
const amberAfter = await amberPixels();
|
||||
check(
|
||||
'the finished area actually PAINTS — amber pixels appear on this surface',
|
||||
amberAfter - amberBefore > 500,
|
||||
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
|
||||
);
|
||||
await shot('01-area-with-label');
|
||||
await shot('03-pins-and-shapes');
|
||||
|
||||
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
|
||||
await page.evaluate(() => window.__gevDrawTool.cancel());
|
||||
await clickWorld(500, 300);
|
||||
await clickWorld(560, 340);
|
||||
check('a new shape is in progress', (await drawState()).vertices === 2);
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
const afterFirstEscape = await drawState();
|
||||
check(
|
||||
'Escape cancels the shape in progress and stays in draw mode',
|
||||
afterFirstEscape.vertices === 0 && afterFirstEscape.active === true,
|
||||
`vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
const afterSecondEscape = await drawState();
|
||||
check(
|
||||
'a second Escape leaves draw mode and gives the pointer back',
|
||||
afterSecondEscape.active === false &&
|
||||
afterSecondEscape.pointerOwner === null &&
|
||||
afterSecondEscape.sceneHandler === false,
|
||||
JSON.stringify(afterSecondEscape),
|
||||
);
|
||||
check(
|
||||
'cancelling never removes a mark already on the board',
|
||||
afterSecondEscape.marks === 3,
|
||||
`${afterSecondEscape.marks} marks`,
|
||||
);
|
||||
|
||||
// ── a click that would select something selects nothing while drawing ───
|
||||
const target = await findClickableTarget();
|
||||
check(
|
||||
'a selectable layer entity is on screen to test click ownership against',
|
||||
Boolean(target),
|
||||
target
|
||||
? `${target.layer} at ${target.x},${target.y}`
|
||||
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
|
||||
);
|
||||
if (target) {
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const selectedNormally = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking a ${target.layer} selects it when nothing owns the pointer`,
|
||||
selectedNormally,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.selectedEntity = undefined;
|
||||
});
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const whileDrawing = await drawState();
|
||||
const selectedWhileDrawing = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
|
||||
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
|
||||
`vertices ${whileDrawing.vertices}, selected ${selectedWhileDrawing}`,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
}
|
||||
|
||||
// ── what a reload actually does ─────────────────────────────────────────
|
||||
const beforeReload = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevAnnotations &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const afterReload = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
check(
|
||||
'a reload starts an empty board — marks live for the session, not across reloads',
|
||||
beforeReload > 0 && afterReload === 0,
|
||||
`${beforeReload} before, ${afterReload} after`,
|
||||
);
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
// The reload built a new page context: re-capture the viewer's own click
|
||||
// actions so the identity comparisons below still mean something.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
});
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(420, 280);
|
||||
await clickWorld(700, 300);
|
||||
await clickWorld(660, 520);
|
||||
await pressFinish();
|
||||
const drewAgain = await waitForMarks(1);
|
||||
check('a shape can be drawn again after a reload', drewAgain);
|
||||
|
||||
// Enter belongs to whatever the keyboard is on. With Clear focused it must
|
||||
// PRESS CLEAR — the shape in progress goes, and so does the mark already on
|
||||
// the board — and it must NOT quietly finish a second shape.
|
||||
await clickWorld(430, 300);
|
||||
await clickWorld(690, 320);
|
||||
await clickWorld(670, 500);
|
||||
const beforeButtonEnter = await drawState();
|
||||
await page.focus('#draw-clear');
|
||||
await page.keyboard.press('Enter');
|
||||
await wait(600);
|
||||
const afterButtonEnter = await drawState();
|
||||
check(
|
||||
'Enter on a keyboard-focused button activates the button, it does not finish the shape',
|
||||
beforeButtonEnter.vertices === 3 &&
|
||||
beforeButtonEnter.marks === 1 &&
|
||||
afterButtonEnter.marks === 0 &&
|
||||
afterButtonEnter.vertices === 0,
|
||||
`before ${beforeButtonEnter.vertices} vertices / ${beforeButtonEnter.marks} marks, ` +
|
||||
`after ${afterButtonEnter.vertices} / ${afterButtonEnter.marks} ` +
|
||||
'(a finish would have made it 2 marks)',
|
||||
);
|
||||
|
||||
// Put one back so the Clear BUTTON check below has something to remove.
|
||||
await clickWorld(420, 280);
|
||||
await clickWorld(700, 300);
|
||||
await clickWorld(660, 520);
|
||||
await pressFinish();
|
||||
check(
|
||||
'a shape can be drawn after the board was cleared from the keyboard',
|
||||
await waitForMarks(1),
|
||||
);
|
||||
await page.click('#draw-clear');
|
||||
await wait(400);
|
||||
const cleared = await drawState();
|
||||
check(
|
||||
'Clear removes every mark from the board',
|
||||
cleared.marks === 0,
|
||||
`${cleared.marks} marks left`,
|
||||
);
|
||||
await shot('04-after-clear');
|
||||
|
||||
// ── a shape that describes nothing is refused, not placed ───────────────
|
||||
const degenerateVertices = await page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
tool.cancel();
|
||||
// Three points on one line, placed through the same addVertex the clicks use.
|
||||
tool.addVertex(-122.42, 37.77);
|
||||
tool.addVertex(-122.42, 37.771);
|
||||
tool.addVertex(-122.42, 37.772);
|
||||
return tool.session?.vertices?.length ?? null;
|
||||
});
|
||||
check(
|
||||
'three collinear vertices are accepted into the session',
|
||||
degenerateVertices === 3,
|
||||
`${degenerateVertices} vertices`,
|
||||
);
|
||||
await pressFinish();
|
||||
await wait(600);
|
||||
const degenerate = await drawState();
|
||||
check(
|
||||
'three points in a line are refused with a reason, not placed',
|
||||
degenerate.marks === 0 && /in a line/.test(degenerate.hint),
|
||||
`${degenerate.marks} marks, hint "${degenerate.hint}"`,
|
||||
);
|
||||
|
||||
// ── a shape drawn across the antimeridian lands where it was drawn ──────
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (179.9995 * Math.PI) / 180,
|
||||
latitude: 0,
|
||||
height: 3000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await wait(2500);
|
||||
await page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
tool.cancel();
|
||||
tool.setShape('area');
|
||||
// A ~200 m square straddling 180.
|
||||
tool.addVertex(179.999, -0.001);
|
||||
tool.addVertex(-179.999, -0.001);
|
||||
tool.addVertex(-179.999, 0.001);
|
||||
tool.addVertex(179.999, 0.001);
|
||||
});
|
||||
await pressFinish();
|
||||
const datelineLanded = await waitForMarks(1);
|
||||
const datelineAnchor = await page.evaluate(() => {
|
||||
const mark = window.__gevAnnotations.list()[0];
|
||||
return mark ? { lon: mark.anchor?.lon, lat: mark.anchor?.lat } : null;
|
||||
});
|
||||
check(
|
||||
'a shape drawn across the antimeridian anchors there, not on the Greenwich meridian',
|
||||
datelineLanded &&
|
||||
datelineAnchor &&
|
||||
Math.abs(Math.abs(datelineAnchor.lon) - 180) < 0.1 &&
|
||||
Math.abs(datelineAnchor.lat) < 0.1,
|
||||
JSON.stringify(datelineAnchor),
|
||||
);
|
||||
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||
|
||||
await page.click('#draw-toggle');
|
||||
await wait(200);
|
||||
const finalState = await drawState();
|
||||
check(
|
||||
'leaving draw mode gives back the pointer, the handler and the double-click',
|
||||
finalState.active === false &&
|
||||
finalState.pointerOwner === null &&
|
||||
finalState.sceneHandler === false &&
|
||||
finalState.previewDataSources === 1,
|
||||
JSON.stringify(finalState),
|
||||
);
|
||||
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
|
||||
const beforeDestroy = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.dataSources.length,
|
||||
);
|
||||
await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case
|
||||
await wait(200);
|
||||
const destroyed = await page.evaluate(async () => {
|
||||
const tool = window.__gevDrawTool;
|
||||
const wasActive = tool.active;
|
||||
await tool.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const Cesium = window.__CESIUM__;
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
let previews = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
||||
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
||||
return {
|
||||
wasActive,
|
||||
diagnostics: tool.diagnostics(),
|
||||
previews,
|
||||
dataSources: viewer.dataSources.length,
|
||||
handleGone: window.__gevDrawTool === undefined,
|
||||
clickRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
|
||||
window.__qaStock.click,
|
||||
doubleRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
|
||||
window.__qaStock.double,
|
||||
drawingClass: document.body.classList.contains('gev-drawing'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'destroy() from an active session releases the pointer, handler and listeners',
|
||||
destroyed.wasActive === true &&
|
||||
destroyed.diagnostics.destroyed === true &&
|
||||
destroyed.diagnostics.pointerOwner === null &&
|
||||
destroyed.diagnostics.sceneHandler === false &&
|
||||
destroyed.diagnostics.domListeners === 0 &&
|
||||
destroyed.drawingClass === false,
|
||||
JSON.stringify(destroyed.diagnostics),
|
||||
);
|
||||
check(
|
||||
'destroy() detaches the preview data source and gives the window handle back',
|
||||
destroyed.previews === 0 &&
|
||||
destroyed.dataSources === beforeDestroy - 1 &&
|
||||
destroyed.handleGone,
|
||||
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
|
||||
);
|
||||
check(
|
||||
"destroy() returns Cesium's own click actions",
|
||||
destroyed.clickRestored && destroyed.doubleRestored,
|
||||
`click ${destroyed.clickRestored}, double ${destroyed.doubleRestored}`,
|
||||
);
|
||||
|
||||
check(
|
||||
'no uncaught page errors across the run',
|
||||
errors.length === 0,
|
||||
errors.join(' | '),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
|
||||
/**
|
||||
* Find something on screen that a layer would select on click, so the ownership
|
||||
* guard can be proved against a real selection rather than a stub. Bundled
|
||||
* infrastructure first (no network, works keyless); CCTV cameras if they are up.
|
||||
*/
|
||||
async function findClickableTarget() {
|
||||
// CCTV first: it is the layer the brief names, and it goes through the shared
|
||||
// tracking click gesture that flights and military use too.
|
||||
const camera = await page
|
||||
.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
await manager.setEnabled('cctv', true, { origin: 'user' });
|
||||
const module = manager.layers.get('cctv')?.module;
|
||||
if (!module?.getUIState) return null;
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const cameras = module.getUIState()?.cameras || [];
|
||||
const found = cameras.find((entry) => {
|
||||
const lat = entry.lat ?? entry.latitude;
|
||||
const lon = entry.lon ?? entry.longitude;
|
||||
return Number.isFinite(lat) && Number.isFinite(lon);
|
||||
});
|
||||
if (found)
|
||||
return {
|
||||
id: found.id,
|
||||
lat: found.lat ?? found.latitude,
|
||||
lon: found.lon ?? found.longitude,
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (camera) {
|
||||
await page.evaluate((spot) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (spot.lon * Math.PI) / 180,
|
||||
latitude: (spot.lat * Math.PI) / 180,
|
||||
height: 600,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, camera);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
|
||||
);
|
||||
if (spot) return { ...spot, layer: 'CCTV camera' };
|
||||
}
|
||||
|
||||
// Bundled infrastructure as the keyless fallback: a different guarded handler,
|
||||
// the same shared claim, and always present without a key.
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'local-datacenters',
|
||||
true,
|
||||
{
|
||||
origin: 'user',
|
||||
},
|
||||
);
|
||||
const centreOf = (entity) => {
|
||||
const time = viewer.clock.currentTime;
|
||||
const point = entity.position?.getValue?.(time);
|
||||
if (point) return point;
|
||||
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
|
||||
if (hierarchy?.positions?.length) return hierarchy.positions[0];
|
||||
const line = entity.polyline?.positions?.getValue?.(time);
|
||||
return line?.length ? line[0] : null;
|
||||
};
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
? window.Cesium.Cartographic.fromCartesian(point)
|
||||
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
|
||||
if (!carto) continue;
|
||||
return {
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!site) return null;
|
||||
await page.evaluate((spot) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (spot.lon * Math.PI) / 180,
|
||||
latitude: (spot.lat * Math.PI) / 180,
|
||||
height: 2500,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates of the first entity matching `belongs` that is on screen
|
||||
* and clear of the side panels. Annotation marks and the draw tool's own
|
||||
* preview vertices are on screen too, so the predicate has to be specific.
|
||||
*/
|
||||
async function onScreen(belongs, timeout = 20_000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
(belongsSource) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const belongsTo = new Function(`return (${belongsSource})`)();
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const collections = [viewer.entities];
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
const source = viewer.dataSources.get(i);
|
||||
if (source?.name === 'gev-draw-preview') continue;
|
||||
collections.push(source.entities);
|
||||
}
|
||||
for (const source of collections) {
|
||||
for (const entity of source.values) {
|
||||
if (!belongsTo(entity)) continue;
|
||||
const position = entity.position?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
);
|
||||
if (!position) continue;
|
||||
const window2d = scene.cartesianToCanvasCoordinates(position);
|
||||
if (!window2d) continue;
|
||||
const { x, y } = window2d;
|
||||
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
|
||||
// Projecting into the window is not the same as being clickable:
|
||||
// the entity may be beyond the horizon or behind a building. Only a
|
||||
// spot where the scene actually picks it proves the control case.
|
||||
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
|
||||
if (!hit?.id || !belongsTo(hit.id)) continue;
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout, polling: 500 },
|
||||
belongs.toString(),
|
||||
)
|
||||
.then(
|
||||
(handle) => handle.jsonValue(),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user