wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import React, { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
|
||||
let DevicePluginHostProvider;
|
||||
let commitPersistedDeviceModelId;
|
||||
let restorePersistedDeviceModelId;
|
||||
|
||||
const hostSourceUrl = new URL(
|
||||
"../src/core/device-plugins/DevicePluginHost.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY,
|
||||
DevicePluginHostProvider,
|
||||
commitPersistedDeviceModelId,
|
||||
restorePersistedDeviceModelId,
|
||||
} = await server.ssrLoadModule("/src/core/device-plugins/DevicePluginHost.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function memoryStorage(seed = {}) {
|
||||
const values = new Map(Object.entries(seed));
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getItem(key) {
|
||||
calls.push(["get", key]);
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
calls.push(["set", key, value]);
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
calls.push(["remove", key]);
|
||||
values.delete(key);
|
||||
},
|
||||
value(key) {
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registryWith(...modelIds) {
|
||||
const registered = new Set(modelIds);
|
||||
return {
|
||||
resolveModel(modelId) {
|
||||
return registered.has(modelId) ? { model: { id: modelId } } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlugin(modelId) {
|
||||
function RuntimeProvider({ activeModel, children }) {
|
||||
return createElement(
|
||||
"div",
|
||||
{ "data-active-model": activeModel?.id ?? "" },
|
||||
children,
|
||||
);
|
||||
}
|
||||
function ConnectionView() {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha1",
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: "test.device.plugin",
|
||||
version: "1.0.0",
|
||||
displayName: "Test device",
|
||||
},
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1",
|
||||
runtime: {
|
||||
backendEntrypoint: "test.device:plugin",
|
||||
isolation: "transitional-in-process",
|
||||
},
|
||||
permissions: [],
|
||||
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
|
||||
models: [{
|
||||
id: modelId,
|
||||
vendor: "Test",
|
||||
displayName: "Test model",
|
||||
category: "test",
|
||||
description: "test",
|
||||
verified: true,
|
||||
capabilities: [],
|
||||
ui: {
|
||||
slot: "device.connection",
|
||||
componentKey: "test.connection",
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
RuntimeProvider,
|
||||
connectionViews: { "test.connection": ConnectionView },
|
||||
};
|
||||
}
|
||||
|
||||
test("persisted model restore admits only an id in the current plugin registry", () => {
|
||||
const key = DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
|
||||
const registry = registryWith("xgrids.lixelkity-k1");
|
||||
const valid = memoryStorage({ [key]: " xgrids.lixelkity-k1 " });
|
||||
|
||||
assert.equal(
|
||||
restorePersistedDeviceModelId(registry, valid),
|
||||
"xgrids.lixelkity-k1",
|
||||
);
|
||||
assert.equal(valid.calls.some(([operation]) => operation === "remove"), false);
|
||||
|
||||
for (const staleValue of ["removed.model", " "]) {
|
||||
const stale = memoryStorage({ [key]: staleValue });
|
||||
assert.equal(restorePersistedDeviceModelId(registry, stale), null);
|
||||
assert.equal(stale.value(key), null, "a stale model id must be cleared");
|
||||
}
|
||||
assert.equal(restorePersistedDeviceModelId(registry, null), null);
|
||||
});
|
||||
|
||||
test("fresh provider mount immediately activates the registry-validated persisted model", () => {
|
||||
const modelId = "test.model.one";
|
||||
const storage = memoryStorage({
|
||||
[DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY]: modelId,
|
||||
});
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { localStorage: storage };
|
||||
try {
|
||||
const markup = renderToStaticMarkup(createElement(
|
||||
DevicePluginHostProvider,
|
||||
{ plugins: [fakePlugin(modelId)] },
|
||||
createElement("span", null, "runtime child"),
|
||||
));
|
||||
assert.match(markup, /data-active-model="test\.model\.one"/);
|
||||
} finally {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("successful selection commits and explicit clear removes the same durable key", () => {
|
||||
const storage = memoryStorage();
|
||||
commitPersistedDeviceModelId("xgrids.lixelkity-k1", storage);
|
||||
assert.equal(
|
||||
storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY),
|
||||
"xgrids.lixelkity-k1",
|
||||
);
|
||||
|
||||
commitPersistedDeviceModelId(null, storage);
|
||||
assert.equal(storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY), null);
|
||||
assert.deepEqual(
|
||||
storage.calls.slice(-1)[0],
|
||||
["remove", DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY],
|
||||
);
|
||||
});
|
||||
|
||||
test("unavailable browser storage fails closed without blocking host state", () => {
|
||||
const denied = {
|
||||
getItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
setItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
removeItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
restorePersistedDeviceModelId(registryWith("xgrids.lixelkity-k1"), denied),
|
||||
null,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
commitPersistedDeviceModelId("xgrids.lixelkity-k1", denied)
|
||||
);
|
||||
assert.doesNotThrow(() => commitPersistedDeviceModelId(null, denied));
|
||||
});
|
||||
|
||||
test("host writes persistence only after plugin deactivation succeeds", () => {
|
||||
const source = readFileSync(hostSourceUrl, "utf8");
|
||||
const failedDeactivation = source.indexOf("if (!(await deactivate()))");
|
||||
const admittedState = source.indexOf("setSelectedModelId(nextModelId);");
|
||||
const durableCommit = source.indexOf(
|
||||
"commitPersistedDeviceModelId(nextModelId, selectionStorage);",
|
||||
admittedState,
|
||||
);
|
||||
|
||||
assert.ok(failedDeactivation >= 0);
|
||||
assert.ok(
|
||||
failedDeactivation < admittedState && admittedState < durableCommit,
|
||||
"failed deactivation branches must return before in-memory and durable commit",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(nextModelId === selectedModelId\) \{[\s\S]*?commitPersistedDeviceModelId\(nextModelId, selectionStorage\);[\s\S]*?return true;/,
|
||||
"explicit clear must remove stale persistence even from an already-empty host",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/useState<string \| null>\(\(\) =>\s*restorePersistedDeviceModelId\(registry, selectionStorage\)/,
|
||||
"a fresh provider mount must restore before runtime providers receive activeModel",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,977 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import React, { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let activeStreamForceFinishAuthority;
|
||||
let activeStreamForceFinishAuthorityMatches;
|
||||
let activeStreamRecoveredBrowserAuthority;
|
||||
let activeStreamRecoveryPresentation;
|
||||
let activeStreamRecoveryPresentationAuthority;
|
||||
let activeStreamRecoveryOwnsPresentationDecision;
|
||||
let exactActiveStreamRecoveryLineage;
|
||||
let formatActiveStreamRecoveryElapsed;
|
||||
let suppressGenericErrorDuringActiveStreamRecovery;
|
||||
let isXgridsActiveStreamRecovery;
|
||||
let K1AcquisitionPipeline;
|
||||
let K1SpatialControlsView;
|
||||
let runSpatialActiveStreamForceFinish;
|
||||
let shouldRenderK1GenericRuntimeError;
|
||||
let shouldRenderK1OperationalPanels;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
|
||||
const hookSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
const acquisitionSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const recoverySurfaceSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const spatialControlsSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const connectionSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamForceFinishAuthorityMatches,
|
||||
activeStreamRecoveredBrowserAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
activeStreamRecoveryPresentationAuthority,
|
||||
activeStreamRecoveryOwnsPresentationDecision,
|
||||
exactActiveStreamRecoveryLineage,
|
||||
formatActiveStreamRecoveryElapsed,
|
||||
suppressGenericErrorDuringActiveStreamRecovery,
|
||||
} = await server.ssrLoadModule("@xgrids-k1/frontend/activeStreamRecovery.ts"));
|
||||
({ isXgridsActiveStreamRecovery, xgridsK1Api } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
({ xgridsK1Actions } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
({ K1AcquisitionPipeline } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/components/K1AcquisitionPipeline.tsx",
|
||||
));
|
||||
({
|
||||
K1SpatialControlsView,
|
||||
runSpatialActiveStreamForceFinish,
|
||||
} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/components/K1SpatialControls.tsx",
|
||||
));
|
||||
({
|
||||
shouldRenderK1GenericRuntimeError,
|
||||
shouldRenderK1OperationalPanels,
|
||||
} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/XgridsK1Connection.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function recoveryContract(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
|
||||
state: "reconnecting",
|
||||
generation: 7,
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
attempt: 3,
|
||||
started_at_utc: "2026-08-11T19:31:00Z",
|
||||
elapsed_ms: 12_400,
|
||||
reason_code: "read-only-rebind-in-progress",
|
||||
force_finish_allowed: true,
|
||||
automatic_read_only_rebind: true,
|
||||
automatic_command_retry: false,
|
||||
start_performed: false,
|
||||
stop_performed: false,
|
||||
ble_operation_performed: false,
|
||||
network_mutation_performed: false,
|
||||
runtime_producer_generation: 11,
|
||||
camera_recovery: "owned",
|
||||
camera_media_state: "pending-first-media",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: {
|
||||
generation: 7,
|
||||
init_committed: true,
|
||||
init_committed_age_ms: 250,
|
||||
first_media_committed: false,
|
||||
first_media_committed_age_ms: null,
|
||||
committed_media_segment_count: 0,
|
||||
last_media_segment_age_ms: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryState(recoveryOverrides = {}, stateOverrides = {}) {
|
||||
return {
|
||||
snapshot_runtime_id: "snapshot-runtime-recovery-001",
|
||||
snapshot_revision: 43,
|
||||
producer_generation: 11,
|
||||
phase: "reconnecting",
|
||||
source_mode: "live",
|
||||
acquisition: {
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
device_id: "device-k1-001",
|
||||
device_session_id: "device-session-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
control_mode: "plugin-commanded",
|
||||
requested_streams: ["spatial.point-cloud.live"],
|
||||
target_host: "127.0.0.1",
|
||||
duration_seconds: 0,
|
||||
evidence_policy: "required",
|
||||
state: "acquiring",
|
||||
state_revision: 9,
|
||||
cleanup_pending: false,
|
||||
},
|
||||
connection_recovery: recoveryContract(recoveryOverrides),
|
||||
...stateOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function coldRestartPrePclState() {
|
||||
const state = recoveryState({
|
||||
generation: 1,
|
||||
acquisition_id: "acquisition-before-backend-restart-001",
|
||||
attempt: 0,
|
||||
started_at_utc: "2026-08-14T00:31:00Z",
|
||||
elapsed_ms: 450,
|
||||
reason_code: "restart-receiver-awaiting-first-pcl",
|
||||
runtime_producer_generation: 1,
|
||||
camera_recovery: "inactive",
|
||||
camera_media_state: "inactive",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: null,
|
||||
}, {
|
||||
snapshot_runtime_id: "snapshot-runtime-after-cold-restart-001",
|
||||
snapshot_revision: 2,
|
||||
producer_generation: 1,
|
||||
acquisition: {
|
||||
...recoveryState().acquisition,
|
||||
acquisition_id: "acquisition-before-backend-restart-001",
|
||||
state: "awaiting_external_start",
|
||||
state_revision: 4,
|
||||
},
|
||||
camera_preview: {
|
||||
activation_admission: {
|
||||
state: "waiting-for-first-authoritative-pcl",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function coldRestartRecoveredState() {
|
||||
const state = coldRestartPrePclState();
|
||||
return {
|
||||
...state,
|
||||
snapshot_revision: state.snapshot_revision + 1,
|
||||
phase: "live",
|
||||
acquisition: {
|
||||
...state.acquisition,
|
||||
state: "acquiring",
|
||||
state_revision: state.acquisition.state_revision + 1,
|
||||
},
|
||||
connection_recovery: {
|
||||
...state.connection_recovery,
|
||||
state: "recovered",
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
force_finish_allowed: false,
|
||||
camera_recovery: "owned",
|
||||
camera_media_state: "pending-epoch",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: null,
|
||||
},
|
||||
camera_preview: {
|
||||
activation_admission: {
|
||||
state: "activating",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function controller(state, overrides = {}) {
|
||||
return {
|
||||
state,
|
||||
pendingAction: null,
|
||||
error: null,
|
||||
physicalStopIntentSpent: false,
|
||||
physicalStopInFlight: false,
|
||||
closeApplicationControlSession: async () => false,
|
||||
prepareCanonicalAcquisition: async () => false,
|
||||
startPreparedAcquisition: async () => false,
|
||||
startReplay: async () => false,
|
||||
stop: async () => false,
|
||||
stopLocalReceiver: async () => false,
|
||||
forceFinishActiveStreamLocally: async () => false,
|
||||
abort: async () => false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPipeline(state, overrides = {}) {
|
||||
return renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
|
||||
controller: controller(state, overrides),
|
||||
desiredConnectionMode: "bridge",
|
||||
openSpatialScene() {},
|
||||
activateAutomaticSpatialSource() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSpatialControls(state, overrides = {}) {
|
||||
return renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: controller(state, overrides),
|
||||
}));
|
||||
}
|
||||
|
||||
function buttonsWithText(markup, text) {
|
||||
return (markup.match(/<button\b[\s\S]*?<\/button>/g) ?? [])
|
||||
.filter((button) => button.includes(text));
|
||||
}
|
||||
|
||||
function sourceSlice(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
|
||||
assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("active recovery contract is strict about all no-write invariants", () => {
|
||||
assert.equal(isXgridsActiveStreamRecovery(recoveryContract()), true);
|
||||
for (const field of [
|
||||
"automatic_command_retry",
|
||||
"start_performed",
|
||||
"stop_performed",
|
||||
"ble_operation_performed",
|
||||
"network_mutation_performed",
|
||||
]) {
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ [field]: true })),
|
||||
false,
|
||||
field,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ state: "retrying-command" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ elapsed_ms: -1 })),
|
||||
false,
|
||||
);
|
||||
const missingMediaState = recoveryContract();
|
||||
delete missingMediaState.camera_media_state;
|
||||
assert.equal(isXgridsActiveStreamRecovery(missingMediaState), false);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ camera_media_ready: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({
|
||||
camera_media_state: "ready",
|
||||
camera_media_ready: true,
|
||||
camera_epoch: {
|
||||
...recoveryContract().camera_epoch,
|
||||
generation: 0,
|
||||
first_media_committed: true,
|
||||
first_media_committed_age_ms: 1,
|
||||
committed_media_segment_count: 1,
|
||||
last_media_segment_age_ms: 1,
|
||||
},
|
||||
})),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("cold restart before first PCL owns exact reconnect UI without camera or physical actions", () => {
|
||||
const state = coldRestartPrePclState();
|
||||
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
|
||||
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
assert.deepEqual(lineage && {
|
||||
runtime: lineage.snapshotRuntimeId,
|
||||
acquisition: lineage.acquisitionId,
|
||||
revision: lineage.acquisitionStateRevision,
|
||||
recovery: lineage.recoveryGeneration,
|
||||
producer: lineage.runtimeProducerGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-after-cold-restart-001",
|
||||
acquisition: "acquisition-before-backend-restart-001",
|
||||
revision: 4,
|
||||
recovery: 1,
|
||||
producer: 1,
|
||||
});
|
||||
assert.notEqual(activeStreamRecoveryPresentationAuthority(state), null);
|
||||
assert.notEqual(activeStreamForceFinishAuthority(state), null);
|
||||
assert.equal(activeStreamRecoveredBrowserAuthority(state), null);
|
||||
assert.equal(state.connection_recovery.camera_recovery, "inactive");
|
||||
assert.equal(state.connection_recovery.camera_media_state, "inactive");
|
||||
assert.equal(state.connection_recovery.camera_media_ready, false);
|
||||
assert.equal(state.connection_recovery.camera_epoch, null);
|
||||
assert.deepEqual(state.camera_preview.activation_admission, {
|
||||
state: "waiting-for-first-authoritative-pcl",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
});
|
||||
|
||||
const pipeline = renderPipeline(state);
|
||||
assert.match(pipeline, /Восстанавливаем соединение/);
|
||||
assert.match(pipeline, /START, STOP, Bluetooth и настройки устройства не отправляются/);
|
||||
assert.equal(buttonsWithText(pipeline, "Прервать соединение").length, 1);
|
||||
assert.equal(buttonsWithText(pipeline, "Запустить приём").length, 0);
|
||||
assert.equal(buttonsWithText(pipeline, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonsWithText(pipeline, "Остановить сканирование").length, 0);
|
||||
assert.doesNotMatch(
|
||||
pipeline,
|
||||
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
|
||||
);
|
||||
|
||||
const spatial = renderSpatialControls(state);
|
||||
assert.match(spatial, /Восстанавливаем соединение/);
|
||||
assert.equal(buttonsWithText(spatial, "Прервать соединение").length, 1);
|
||||
assert.doesNotMatch(
|
||||
spatial,
|
||||
/Остановить устройство|Остановить K1|Завершить локальный приём/,
|
||||
);
|
||||
});
|
||||
|
||||
test("cold restart first PCL preserves lineage and renders recovered continuation copy", () => {
|
||||
const beforePcl = coldRestartPrePclState();
|
||||
const state = coldRestartRecoveredState();
|
||||
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
|
||||
assert.equal(
|
||||
state.connection_recovery.acquisition_id,
|
||||
beforePcl.connection_recovery.acquisition_id,
|
||||
);
|
||||
assert.equal(
|
||||
state.connection_recovery.generation,
|
||||
beforePcl.connection_recovery.generation,
|
||||
);
|
||||
assert.equal(
|
||||
state.connection_recovery.runtime_producer_generation,
|
||||
beforePcl.connection_recovery.runtime_producer_generation,
|
||||
);
|
||||
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
const browserAuthority = activeStreamRecoveredBrowserAuthority(state);
|
||||
assert.deepEqual(lineage && {
|
||||
runtime: lineage.snapshotRuntimeId,
|
||||
acquisition: lineage.acquisitionId,
|
||||
revision: lineage.acquisitionStateRevision,
|
||||
recovery: lineage.recoveryGeneration,
|
||||
producer: lineage.runtimeProducerGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-after-cold-restart-001",
|
||||
acquisition: "acquisition-before-backend-restart-001",
|
||||
revision: 5,
|
||||
recovery: 1,
|
||||
producer: 1,
|
||||
});
|
||||
assert.deepEqual(browserAuthority, lineage);
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(state), null);
|
||||
assert.equal(activeStreamForceFinishAuthority(state), null);
|
||||
assert.equal(state.connection_recovery.camera_recovery, "owned");
|
||||
assert.equal(state.camera_preview.activation_admission.device_command_sent, false);
|
||||
|
||||
const pipeline = renderPipeline(state);
|
||||
assert.doesNotMatch(pipeline, /Восстанавливаем соединение|Прервать соединение/);
|
||||
assert.match(pipeline, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
|
||||
assert.match(pipeline, /Связь восстановлена · приём продолжается/);
|
||||
assert.match(pipeline, /Продолжаем тот же приём без нового START/);
|
||||
assert.doesNotMatch(pipeline, /Назовите проект и запустите приём|Запустить приём/);
|
||||
});
|
||||
|
||||
test("force-finish authority requires exact runtime, acquisition, revision and generations", () => {
|
||||
const state = recoveryState();
|
||||
const authority = activeStreamForceFinishAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
snapshotRuntimeId: authority.snapshotRuntimeId,
|
||||
acquisitionId: authority.acquisitionId,
|
||||
acquisitionStateRevision: authority.acquisitionStateRevision,
|
||||
recoveryGeneration: authority.recoveryGeneration,
|
||||
runtimeProducerGeneration: authority.runtimeProducerGeneration,
|
||||
}, {
|
||||
snapshotRuntimeId: "snapshot-runtime-recovery-001",
|
||||
acquisitionId: "acquisition-recovery-001",
|
||||
acquisitionStateRevision: 9,
|
||||
recoveryGeneration: 7,
|
||||
runtimeProducerGeneration: 11,
|
||||
});
|
||||
assert.equal(activeStreamForceFinishAuthorityMatches(authority, state), true);
|
||||
|
||||
const staleCases = [
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.snapshot_runtime_id = "snapshot-runtime-recovery-002";
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.acquisition.acquisition_id = "acquisition-recovery-002";
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.acquisition.state_revision += 1;
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.connection_recovery.generation += 1;
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.producer_generation += 1;
|
||||
return value;
|
||||
})(),
|
||||
];
|
||||
for (const stale of staleCases) {
|
||||
assert.equal(activeStreamForceFinishAuthorityMatches(authority, stale), false);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
activeStreamForceFinishAuthority(recoveryState({ state: "recovered", force_finish_allowed: false })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
activeStreamForceFinishAuthority(recoveryState({ force_finish_allowed: false })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnecting presentation is exact and owns stale supervisor projection", () => {
|
||||
const state = recoveryState({ force_finish_allowed: false });
|
||||
const authority = activeStreamRecoveryPresentationAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
runtime: authority.snapshotRuntimeId,
|
||||
acquisition: authority.acquisitionId,
|
||||
producer: authority.runtimeProducerGeneration,
|
||||
recovery: authority.recoveryGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-recovery-001",
|
||||
acquisition: "acquisition-recovery-001",
|
||||
producer: 11,
|
||||
recovery: 7,
|
||||
});
|
||||
assert.equal(activeStreamRecoveryOwnsPresentationDecision(state), true);
|
||||
|
||||
const wrongProducer = structuredClone(state);
|
||||
wrongProducer.producer_generation += 1;
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(wrongProducer), null);
|
||||
assert.equal(
|
||||
activeStreamRecoveryOwnsPresentationDecision(wrongProducer),
|
||||
true,
|
||||
"a valid reconnect contract must block stale ordinary data projection",
|
||||
);
|
||||
|
||||
for (const recoveryStateName of [
|
||||
"blocked",
|
||||
"standby",
|
||||
"fault",
|
||||
"force-finishing",
|
||||
"force-finished",
|
||||
]) {
|
||||
const terminal = recoveryState({ state: recoveryStateName });
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(terminal), null);
|
||||
assert.equal(activeStreamRecoveryOwnsPresentationDecision(terminal), true);
|
||||
}
|
||||
assert.equal(
|
||||
activeStreamRecoveryOwnsPresentationDecision(recoveryState({ state: "recovered" })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recovered browser carryover keeps exact lineage without restoring recovery controls", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "live",
|
||||
});
|
||||
const authority = activeStreamRecoveredBrowserAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
runtime: authority.snapshotRuntimeId,
|
||||
acquisition: authority.acquisitionId,
|
||||
revision: authority.acquisitionStateRevision,
|
||||
producer: authority.runtimeProducerGeneration,
|
||||
recovery: authority.recoveryGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-recovery-001",
|
||||
acquisition: "acquisition-recovery-001",
|
||||
revision: 9,
|
||||
producer: 11,
|
||||
recovery: 7,
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
assert.equal(activeStreamForceFinishAuthority(state), null);
|
||||
|
||||
const staleProducer = structuredClone(state);
|
||||
staleProducer.producer_generation += 1;
|
||||
assert.equal(activeStreamRecoveredBrowserAuthority(staleProducer), null);
|
||||
assert.equal(
|
||||
activeStreamRecoveredBrowserAuthority(recoveryState({
|
||||
state: "recovered",
|
||||
camera_recovery: "blocked",
|
||||
force_finish_allowed: false,
|
||||
}, { phase: "live" })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("only an exact reconnecting lineage suppresses the generic red error", () => {
|
||||
const state = recoveryState();
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(state, null), true);
|
||||
assert.equal(
|
||||
shouldRenderK1GenericRuntimeError("Ошибка локального приёмника", false, state, null),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
suppressGenericErrorDuringActiveStreamRecovery(state, "force-finish"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderK1GenericRuntimeError(
|
||||
"Локальное завершение не выполнено",
|
||||
false,
|
||||
state,
|
||||
"force-finish",
|
||||
),
|
||||
true,
|
||||
"a failed explicit local finish must keep the generic error banner visible",
|
||||
);
|
||||
|
||||
const wrongProducer = structuredClone(state);
|
||||
wrongProducer.producer_generation += 1;
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongProducer), false);
|
||||
|
||||
const wrongAcquisition = structuredClone(state);
|
||||
wrongAcquisition.connection_recovery.acquisition_id = "acquisition-stale";
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongAcquisition), false);
|
||||
|
||||
const missingRuntime = structuredClone(state);
|
||||
delete missingRuntime.snapshot_runtime_id;
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(missingRuntime), false);
|
||||
|
||||
const blocked = recoveryState({ state: "blocked" });
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(blocked), false);
|
||||
|
||||
const nonOwned = recoveryState({ automatic_read_only_rebind: false });
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(nonOwned), false);
|
||||
|
||||
const inactiveCases = [
|
||||
recoveryState({}, { phase: "error" }),
|
||||
recoveryState({}, { source_mode: "idle" }),
|
||||
recoveryState({}, {
|
||||
acquisition: {
|
||||
...state.acquisition,
|
||||
state: "failed",
|
||||
},
|
||||
}),
|
||||
];
|
||||
for (const inactive of inactiveCases) {
|
||||
assert.equal(
|
||||
suppressGenericErrorDuringActiveStreamRecovery(inactive, null),
|
||||
false,
|
||||
"stale recovery projection must fail open to the error banner",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reconnecting presentation is neutral, timed and exposes explicit local finish", () => {
|
||||
const state = recoveryState();
|
||||
const presentation = activeStreamRecoveryPresentation(state);
|
||||
assert.equal(presentation?.state, "reconnecting");
|
||||
assert.equal(presentation?.tone, "neutral");
|
||||
assert.equal(presentation?.progressLabel, "Попытка 3 · 12 с");
|
||||
assert.equal(presentation?.forceFinishAvailable, true);
|
||||
|
||||
const markup = renderPipeline(state);
|
||||
assert.match(markup, /Восстанавливаем соединение/);
|
||||
assert.match(markup, /Попытка 3 · 12 с/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
|
||||
assert.match(markup, /START, STOP, Bluetooth и настройки устройства не отправляются/);
|
||||
assert.doesNotMatch(markup, /Ошибка локального приёмника/);
|
||||
assert.equal(shouldRenderK1OperationalPanels(state), true);
|
||||
});
|
||||
|
||||
test("spatial scene owns the same recovery spinner and explicit local finish", () => {
|
||||
const markup = renderSpatialControls(recoveryState());
|
||||
assert.match(markup, /Восстанавливаем соединение/);
|
||||
assert.match(markup, /Попытка 3 · 12 с/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.match(markup, /data-recovery-state="reconnecting"/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
|
||||
assert.match(markup, /локальный front\/back-приём/);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/Остановить устройство|Остановить K1|Завершить локальный приём/,
|
||||
"recovery must not expose canonical STOP or the generic receiver stop",
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial blocked and fault recovery copy is terminal and truthful", () => {
|
||||
const blocked = renderSpatialControls(recoveryState({
|
||||
state: "blocked",
|
||||
reason_code: "exact-binding-changed",
|
||||
}));
|
||||
assert.match(blocked, /Связь не восстановлена/);
|
||||
assert.match(blocked, /Восстановление остановлено/);
|
||||
assert.doesNotMatch(blocked, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(blocked, "Прервать соединение").length, 1);
|
||||
|
||||
const fault = renderSpatialControls(recoveryState({
|
||||
state: "fault",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "active-stream-recovery-system-error",
|
||||
}));
|
||||
assert.match(fault, /K1 сообщил об ошибке/);
|
||||
assert.match(fault, /Автоматических команд и повторов нет/);
|
||||
assert.equal(buttonsWithText(fault, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("spatial recovery interaction routes only to exact local force-finish", async () => {
|
||||
let forceFinishCalls = 0;
|
||||
const current = recoveryState();
|
||||
const invoked = await runSpatialActiveStreamForceFinish({
|
||||
state: current,
|
||||
forceFinishActiveStreamLocally: async () => {
|
||||
forceFinishCalls += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(invoked, true);
|
||||
assert.equal(forceFinishCalls, 1);
|
||||
|
||||
const stale = structuredClone(current);
|
||||
stale.connection_recovery.runtime_producer_generation += 1;
|
||||
const rejected = await runSpatialActiveStreamForceFinish({
|
||||
state: stale,
|
||||
forceFinishActiveStreamLocally: async () => {
|
||||
forceFinishCalls += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(rejected, false);
|
||||
assert.equal(forceFinishCalls, 1, "stale lineage must not dispatch any action");
|
||||
});
|
||||
|
||||
test("spatial force-finish pending owns the surface without a second action", () => {
|
||||
const markup = renderSpatialControls(
|
||||
recoveryState({
|
||||
state: "force-finishing",
|
||||
acquisition_id: null,
|
||||
force_finish_allowed: false,
|
||||
automatic_read_only_rebind: false,
|
||||
camera_recovery: "inactive",
|
||||
}),
|
||||
{ pendingAction: "force-finish" },
|
||||
);
|
||||
assert.match(markup, /Завершаем локальный приём/);
|
||||
assert.match(markup, /data-recovery-state="force-finishing"/);
|
||||
assert.match(markup, /Команда STOP устройству не отправляется/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("blocked, camera-blocked, standby and fault copy stay truthful", () => {
|
||||
const blocked = recoveryState({ state: "blocked", reason_code: "exact-binding-changed" });
|
||||
const blockedMarkup = renderPipeline(blocked);
|
||||
assert.match(blockedMarkup, /Связь не восстановлена/);
|
||||
assert.match(blockedMarkup, /Восстановление остановлено/);
|
||||
assert.doesNotMatch(blockedMarkup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(blockedMarkup, "Прервать соединение").length, 1);
|
||||
|
||||
const cameraBlocked = recoveryState({
|
||||
state: "blocked",
|
||||
camera_recovery: "blocked",
|
||||
reason_code: "camera-recovery-failed",
|
||||
});
|
||||
assert.match(renderPipeline(cameraBlocked), /Видеопоток не восстановлен/);
|
||||
|
||||
const standby = recoveryState({
|
||||
state: "standby",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "device-reported-standby",
|
||||
});
|
||||
const standbyMarkup = renderPipeline(standby);
|
||||
assert.match(standbyMarkup, /Устройство перешло в ожидание/);
|
||||
assert.match(standbyMarkup, /без команды STOP/);
|
||||
assert.equal(buttonsWithText(standbyMarkup, "Прервать соединение").length, 0);
|
||||
assert.equal(shouldRenderK1OperationalPanels(standby), true);
|
||||
|
||||
const fault = recoveryState({
|
||||
state: "fault",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "active-stream-recovery-system-error",
|
||||
});
|
||||
const faultMarkup = renderPipeline(fault);
|
||||
assert.match(faultMarkup, /K1 сообщил об ошибке/);
|
||||
assert.match(faultMarkup, /Автоматических команд и повторов нет/);
|
||||
assert.equal(buttonsWithText(faultMarkup, "Прервать соединение").length, 0);
|
||||
assert.equal(shouldRenderK1OperationalPanels(fault), true);
|
||||
});
|
||||
|
||||
test("recovered active lineage renders the continued session and one exact STOP", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "live",
|
||||
compatibility: {
|
||||
vendor_writes_enabled: true,
|
||||
permitted_mode: "active-control",
|
||||
},
|
||||
application_control_session: {
|
||||
session_generation: 5,
|
||||
state_revision: 8,
|
||||
state: "scanning",
|
||||
can_stop: true,
|
||||
control_socket_open: true,
|
||||
},
|
||||
connection_policy: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
allowed_actions: ["stop-acquisition"],
|
||||
actions: {
|
||||
"stop-acquisition": {
|
||||
allowed: true,
|
||||
reason_codes: [],
|
||||
target_source: "connection-supervisor",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
const markup = renderPipeline(state);
|
||||
assert.doesNotMatch(markup, /Восстанавливаем соединение|Прервать соединение/);
|
||||
assert.match(markup, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
|
||||
assert.match(markup, /Связь восстановлена · приём продолжается/);
|
||||
assert.match(markup, /Продолжаем тот же приём без нового START/);
|
||||
assert.doesNotMatch(markup, /Назовите проект и запустите приём|Запустить приём/);
|
||||
|
||||
const stopButtons = buttonsWithText(markup, "Остановить устройство и запись");
|
||||
assert.equal(stopButtons.length, 1);
|
||||
assert.doesNotMatch(stopButtons[0], /\bdisabled(?:=|\s|>)/);
|
||||
});
|
||||
|
||||
test("stale recovered marker on an idle released runtime fails closed to idle UI", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "idle",
|
||||
source_mode: "idle",
|
||||
acquisition: {
|
||||
...recoveryState().acquisition,
|
||||
state: "completed",
|
||||
cleanup_pending: false,
|
||||
},
|
||||
application_control_session: {
|
||||
session_generation: 5,
|
||||
state_revision: 9,
|
||||
state: "completed",
|
||||
can_stop: false,
|
||||
control_socket_open: false,
|
||||
},
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
const markup = renderPipeline(state);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
|
||||
);
|
||||
assert.match(markup, /Назовите проект и запустите приём/);
|
||||
assert.equal(buttonsWithText(markup, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonsWithText(markup, "Остановить сканирование").length, 0);
|
||||
});
|
||||
|
||||
test("force-finishing shows one local-only pending owner and no second action", () => {
|
||||
const state = recoveryState({
|
||||
state: "force-finishing",
|
||||
acquisition_id: null,
|
||||
force_finish_allowed: false,
|
||||
automatic_read_only_rebind: false,
|
||||
runtime_producer_generation: 11,
|
||||
camera_recovery: "inactive",
|
||||
});
|
||||
const markup = renderPipeline(state, { pendingAction: "force-finish" });
|
||||
assert.match(markup, /Завершаем локальный приём/);
|
||||
assert.match(markup, /Команда STOP устройству не отправляется/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("elapsed presentation is deterministic", () => {
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(null), null);
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(-1), null);
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(999), "0 с");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(59_999), "59 с");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(60_000), "1 мин");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(125_900), "2 мин 5 с");
|
||||
});
|
||||
|
||||
test("force-finish manifest/API sends the exact fenced local-only request", async () => {
|
||||
assert.equal(
|
||||
xgridsK1Actions.acquisitionForceFinishLocal,
|
||||
"acquisition.force-finish-local",
|
||||
);
|
||||
const request = {
|
||||
expected_snapshot_runtime_id: "snapshot-runtime-recovery-001",
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
expected_state_revision: 9,
|
||||
expected_recovery_generation: 7,
|
||||
operator_confirmed: true,
|
||||
operation_id: "op-00000000-0000-4000-8000-000000000321",
|
||||
idempotency_key:
|
||||
"acquisition.force-finish-local:op-00000000-0000-4000-8000-000000000321",
|
||||
deadline_seconds: 30,
|
||||
};
|
||||
let capturedUrl = null;
|
||||
let capturedInit = null;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
capturedUrl = String(input);
|
||||
capturedInit = init;
|
||||
return new Response(JSON.stringify({ state: recoveryState() }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
await xgridsK1Api.forceFinishAcquisitionLocally(request);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.match(
|
||||
capturedUrl,
|
||||
/\/actions\/acquisition\.force-finish-local$/,
|
||||
);
|
||||
assert.equal(capturedInit.method, "POST");
|
||||
assert.deepEqual(JSON.parse(capturedInit.body), { input: request });
|
||||
});
|
||||
|
||||
test("state API rejects a drifted active recovery contract", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
state: recoveryState({ stop_performed: true }),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => xgridsK1Api.getState(),
|
||||
/некорректное состояние/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("frontend boundary keeps recovery automatic work read-only and local finish explicit", () => {
|
||||
const hookSource = readFileSync(hookSourceUrl, "utf8");
|
||||
const forceFinish = sourceSlice(
|
||||
hookSource,
|
||||
"const forceFinishActiveStreamLocally",
|
||||
"const abort",
|
||||
);
|
||||
assert.match(forceFinish, /run\("force-finish"/);
|
||||
assert.match(forceFinish, /activeStreamForceFinishAuthority\(latestState\.current\)/);
|
||||
assert.match(forceFinish, /expected_snapshot_runtime_id:\s*authority\.snapshotRuntimeId/);
|
||||
assert.match(forceFinish, /acquisition_id:\s*authority\.acquisitionId/);
|
||||
assert.match(forceFinish, /expected_state_revision:\s*authority\.acquisitionStateRevision/);
|
||||
assert.match(forceFinish, /expected_recovery_generation:\s*authority\.recoveryGeneration/);
|
||||
assert.match(forceFinish, /operator_confirmed:\s*true/);
|
||||
assert.match(
|
||||
forceFinish,
|
||||
/newMutationContext\("acquisition\.force-finish-local"\)/,
|
||||
);
|
||||
assert.equal(
|
||||
(forceFinish.match(/forceFinishAcquisitionLocally\(/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
forceFinish,
|
||||
/startAcquisition|stopAcquisition|scanBle|selectCameraPreview|connect\(/,
|
||||
);
|
||||
|
||||
const acquisitionSource = readFileSync(acquisitionSourceUrl, "utf8");
|
||||
assert.equal(
|
||||
(acquisitionSource.match(/forceFinishActiveStreamLocally\(\)/g) ?? []).length,
|
||||
1,
|
||||
"the explicit recovery button is the only frontend caller",
|
||||
);
|
||||
const recoverySurface = readFileSync(recoverySurfaceSourceUrl, "utf8");
|
||||
assert.match(recoverySurface, /<ActivityIndicator/);
|
||||
assert.match(recoverySurface, /<Button[\s\S]*?variant="secondary"/);
|
||||
assert.doesNotMatch(recoverySurface, /<button\b|style=\{/);
|
||||
|
||||
const spatialSource = readFileSync(spatialControlsSourceUrl, "utf8");
|
||||
assert.match(spatialSource, /variant="compact"/);
|
||||
assert.match(
|
||||
spatialSource,
|
||||
/runSpatialActiveStreamForceFinish\(\{[\s\S]*?forceFinishActiveStreamLocally/,
|
||||
);
|
||||
const spatialForceFinish = sourceSlice(
|
||||
spatialSource,
|
||||
"export function runSpatialActiveStreamForceFinish",
|
||||
"export function K1SpatialControlsView",
|
||||
);
|
||||
assert.match(spatialForceFinish, /activeStreamForceFinishAuthority\(controller\.state\)/);
|
||||
assert.equal(
|
||||
(spatialForceFinish.match(/controller\.forceFinishActiveStreamLocally\(\)/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(spatialForceFinish, /\bstop\(|stopLocalReceiver|start|connect|scanBle/);
|
||||
|
||||
const connectionSource = readFileSync(connectionSourceUrl, "utf8");
|
||||
assert.match(
|
||||
connectionSource,
|
||||
/shouldRenderK1GenericRuntimeError\([\s\S]*?errorCorrelation\?\.action/,
|
||||
);
|
||||
assert.match(
|
||||
connectionSource,
|
||||
/\{showGenericRuntimeError && error \? \([\s\S]*?<K1OperatorError/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let advanceLiveReceiverOpenWatchdog;
|
||||
let advanceLiveReceiverWatchdog;
|
||||
let initialLiveReceiverOpenWatchdogState;
|
||||
let initialLiveReceiverWatchdogState;
|
||||
let initialLiveReceiverRecoveryState;
|
||||
let liveReceiverRecoveryAuthorityIsCurrent;
|
||||
let liveReceiverRecoveryRetryDelay;
|
||||
let liveRerunRecoveryAuthorityIdentity;
|
||||
let requestLiveReceiverRecovery;
|
||||
|
||||
before(async () => {
|
||||
@@ -16,9 +21,14 @@ before(async () => {
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverOpenWatchdogState,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
liveReceiverRecoveryAuthorityIsCurrent,
|
||||
liveReceiverRecoveryRetryDelay,
|
||||
liveRerunRecoveryAuthorityIdentity,
|
||||
requestLiveReceiverRecovery,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveReceiverWatchdog.ts"));
|
||||
});
|
||||
@@ -92,3 +102,217 @@ test("startup failures request only three bounded viewer restarts", () => {
|
||||
assert.equal(exhausted.attempt, 3);
|
||||
assert.equal(exhausted.state.awaitingRecovery, false);
|
||||
});
|
||||
|
||||
function livePointCloudDescriptor(overrides = {}) {
|
||||
return {
|
||||
id: "xgrids-k1:lixelkity-k1:sensor.lidar.primary",
|
||||
sourceId: "sensor.lidar.primary",
|
||||
semanticChannelId: "spatial.point-cloud.live",
|
||||
label: "K1 point cloud",
|
||||
description: "live",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: "streaming",
|
||||
transport: "rerun-grpc",
|
||||
endpointLabel: "Rerun gRPC",
|
||||
previewUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||
delivery: null,
|
||||
activation: null,
|
||||
presentationLease: null,
|
||||
provider: {
|
||||
pluginId: "xgrids-k1",
|
||||
pluginVersion: "0.1.0",
|
||||
modelId: "lixelkity-k1",
|
||||
compatibilityProfileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
},
|
||||
binding: {
|
||||
deviceId: "device-k1-001",
|
||||
deviceSessionId: "device-session-001",
|
||||
acquisitionId: "acquisition-001",
|
||||
},
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "live-only",
|
||||
seekable: false,
|
||||
sessionRecording: false,
|
||||
clockId: "acquisition-001",
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const liveSpatialSource = {
|
||||
id: "acquisition-001",
|
||||
url: "rerun+http://127.0.0.1:9877/proxy",
|
||||
label: "Live",
|
||||
kind: "rerun-grpc",
|
||||
};
|
||||
|
||||
test("exact live Rerun authority gets durable retries with capped delay", () => {
|
||||
const authority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor(),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(authority);
|
||||
assert.equal(liveReceiverRecoveryAuthorityIsCurrent(authority, authority), true);
|
||||
|
||||
let state = initialLiveReceiverRecoveryState();
|
||||
const delays = [];
|
||||
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
||||
const recovery = requestLiveReceiverRecovery(state, {
|
||||
activeAuthorityIdentity: authority,
|
||||
expectedAuthorityIdentity: authority,
|
||||
});
|
||||
assert.equal(recovery.signal, "retry");
|
||||
assert.equal(recovery.attempt, attempt);
|
||||
delays.push(recovery.delayMs);
|
||||
state = recovery.state;
|
||||
}
|
||||
assert.deepEqual(delays, [400, 1_000, 2_000, 5_000, 5_000, 5_000, 5_000, 5_000]);
|
||||
assert.equal(state.attempts, 8);
|
||||
assert.equal(liveReceiverRecoveryRetryDelay(100), 5_000);
|
||||
});
|
||||
|
||||
test("Rerun durable retry fails closed when exact authority is replaced", () => {
|
||||
const authority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor(),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(authority);
|
||||
const replacement = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
binding: {
|
||||
deviceId: "device-k1-001",
|
||||
deviceSessionId: "device-session-002",
|
||||
acquisitionId: "acquisition-002",
|
||||
},
|
||||
capabilities: {
|
||||
...livePointCloudDescriptor().capabilities,
|
||||
clockId: "acquisition-002",
|
||||
},
|
||||
}),
|
||||
{ ...liveSpatialSource, id: "acquisition-002" },
|
||||
);
|
||||
assert.ok(replacement);
|
||||
|
||||
const stale = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState(), {
|
||||
activeAuthorityIdentity: replacement,
|
||||
expectedAuthorityIdentity: authority,
|
||||
});
|
||||
assert.equal(stale.signal, "stale");
|
||||
assert.equal(stale.delayMs, null);
|
||||
assert.deepEqual(stale.state, initialLiveReceiverRecoveryState());
|
||||
});
|
||||
|
||||
test("connecting Rerun authority requires an exact recovery generation lease", () => {
|
||||
const recoveryLease = {
|
||||
kind: "active-stream-recovery",
|
||||
runtimeId: "runtime-recovery-001",
|
||||
acquisitionId: "acquisition-001",
|
||||
acquisitionStateRevision: 4,
|
||||
producerGeneration: 17,
|
||||
recoveryGeneration: 6,
|
||||
};
|
||||
const recoveredAuthority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
availability: "connecting",
|
||||
presentationLease: recoveryLease,
|
||||
}),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(recoveredAuthority);
|
||||
assert.equal(
|
||||
liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({ availability: "connecting" }),
|
||||
liveSpatialSource,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
availability: "connecting",
|
||||
presentationLease: { ...recoveryLease, producerGeneration: 0 },
|
||||
}),
|
||||
liveSpatialSource,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening receiver gets bounded rolling patience while backend publication advances", () => {
|
||||
let openState = initialLiveReceiverOpenWatchdogState(0, 0);
|
||||
let recoveryState = initialLiveReceiverRecoveryState();
|
||||
|
||||
const samples = [
|
||||
[134, 3_999, "wait-for-store"],
|
||||
[266, 4_000, "refresh-receiver"],
|
||||
];
|
||||
for (const [backendActivitySequence, nowMs, expectedSignal] of samples) {
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
recoveryState,
|
||||
backendActivitySequence,
|
||||
nowMs,
|
||||
);
|
||||
assert.equal(observed.signal, expectedSignal);
|
||||
assert.equal(observed.state.lastBackendActivitySequence, backendActivitySequence);
|
||||
assert.deepEqual(observed.recoveryState, {
|
||||
attempts: 0,
|
||||
awaitingRecovery: false,
|
||||
});
|
||||
openState = observed.state;
|
||||
recoveryState = observed.recoveryState;
|
||||
}
|
||||
});
|
||||
|
||||
test("unchanged opening sequence delegates to bounded receiver restart", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486);
|
||||
const recoveryState = initialLiveReceiverRecoveryState();
|
||||
const unchanged = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
recoveryState,
|
||||
486,
|
||||
);
|
||||
|
||||
assert.equal(unchanged.signal, "restart-receiver");
|
||||
const restart = requestLiveReceiverRecovery(unchanged.recoveryState);
|
||||
assert.equal(restart.signal, "retry");
|
||||
assert.equal(restart.attempt, 1);
|
||||
});
|
||||
|
||||
test("fresh backend progress preserves earlier restart debt until viewer admission", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
|
||||
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
consumedRestart.state,
|
||||
600,
|
||||
3_999,
|
||||
);
|
||||
|
||||
assert.equal(observed.signal, "wait-for-store");
|
||||
assert.deepEqual(observed.recoveryState, {
|
||||
attempts: 1,
|
||||
awaitingRecovery: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("aged active receiver refresh does not spend or erase restart debt", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
|
||||
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
consumedRestart.state,
|
||||
900,
|
||||
4_000,
|
||||
);
|
||||
|
||||
assert.equal(observed.signal, "refresh-receiver");
|
||||
assert.deepEqual(observed.recoveryState, consumedRestart.state);
|
||||
assert.equal(observed.openForMs, 4_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let createLiveViewerDiagnosticLifecycle;
|
||||
let createAbortFencedBuildVerifier;
|
||||
let createUiBuildStaleCoordinator;
|
||||
let liveViewerDiagnosticBody;
|
||||
let server;
|
||||
let uiBuildIdFromModuleScripts;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createAbortFencedBuildVerifier,
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createUiBuildStaleCoordinator,
|
||||
liveViewerDiagnosticBody,
|
||||
uiBuildIdFromModuleScripts,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function createFakeScheduler() {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const jobs = new Map();
|
||||
const schedule = (callback, delay, interval) => {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
jobs.set(handle, { callback, due: now + delay, interval });
|
||||
return handle;
|
||||
};
|
||||
const clear = (handle) => jobs.delete(handle);
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout: (callback, delay) => schedule(callback, delay, null),
|
||||
clearTimeout: clear,
|
||||
setInterval: (callback, delay) => schedule(callback, delay, delay),
|
||||
clearInterval: clear,
|
||||
},
|
||||
advance(milliseconds) {
|
||||
const target = now + milliseconds;
|
||||
while (true) {
|
||||
const next = [...jobs.entries()]
|
||||
.filter(([, job]) => job.due <= target)
|
||||
.sort((left, right) => left[1].due - right[1].due)[0];
|
||||
if (!next) break;
|
||||
const [handle, job] = next;
|
||||
now = job.due;
|
||||
if (job.interval === null) jobs.delete(handle);
|
||||
else job.due += job.interval;
|
||||
job.callback();
|
||||
}
|
||||
now = target;
|
||||
},
|
||||
pending: () => jobs.size,
|
||||
};
|
||||
}
|
||||
|
||||
const lineage = (viewerInstanceId, lifecycleGeneration = 1) => ({
|
||||
uiBuildId: "/assets/index-abcdefgh.js",
|
||||
documentInstanceId: "00000000-0000-4000-8000-000000000001",
|
||||
viewerInstanceId,
|
||||
lifecycleGeneration,
|
||||
});
|
||||
|
||||
test("mounted viewer admission terminally fences 60 seconds of stale timers", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const callbacks = [];
|
||||
const posts = [];
|
||||
const lifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000011"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
|
||||
lifecycle.armAdmissionTimeout(() => callbacks.push("timeout"), 12_000);
|
||||
lifecycle.armAdmissionInterval(() => callbacks.push("discovery"), 100);
|
||||
lifecycle.markAdmitted();
|
||||
lifecycle.post({ eventCode: "live_receiver_active_store_admitted" });
|
||||
clock.advance(60_000);
|
||||
|
||||
assert.deepEqual(callbacks, []);
|
||||
assert.equal(clock.pending(), 0);
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(posts[0].eventLineage.lifecycleGeneration, 1);
|
||||
});
|
||||
|
||||
test("two mounted viewers keep timer and diagnostic lineage isolated", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const posts = [];
|
||||
const first = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000021"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
const second = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000022", 7),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
|
||||
first.armAdmissionTimeout(() => {
|
||||
first.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
second.armAdmissionTimeout(() => {
|
||||
second.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
first.markAdmitted();
|
||||
clock.advance(12_000);
|
||||
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(
|
||||
posts[0].eventLineage.viewerInstanceId,
|
||||
"00000000-0000-4000-8000-000000000022",
|
||||
);
|
||||
assert.equal(posts[0].eventLineage.lifecycleGeneration, 7);
|
||||
});
|
||||
|
||||
test("stale-build and unmount fence callbacks before one reload", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const order = [];
|
||||
const posts = [];
|
||||
const lifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000031"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event) => posts.push(event),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
lifecycle.armAdmissionTimeout(() => {
|
||||
lifecycle.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
const coordinator = createUiBuildStaleCoordinator({
|
||||
scheduleReload: (callback, delay) => {
|
||||
order.push(`scheduled:${delay}`);
|
||||
clock.scheduler.setTimeout(callback, delay);
|
||||
},
|
||||
reload: () => order.push("reload"),
|
||||
});
|
||||
coordinator.subscribe(() => {
|
||||
order.push("local-transports-closed");
|
||||
lifecycle.dispose();
|
||||
});
|
||||
|
||||
coordinator.report({
|
||||
loadedUiBuildId: "/assets/index-abcdefgh.js",
|
||||
expectedUiBuildId: "/assets/index-ijklmnop.js",
|
||||
});
|
||||
coordinator.report({
|
||||
loadedUiBuildId: "/assets/index-abcdefgh.js",
|
||||
expectedUiBuildId: "/assets/index-qrstuvwx.js",
|
||||
});
|
||||
clock.advance(60_000);
|
||||
lifecycle.post({ eventCode: "live_receiver_error" });
|
||||
|
||||
assert.deepEqual(order, ["local-transports-closed", "scheduled:50", "reload"]);
|
||||
assert.deepEqual(posts, []);
|
||||
assert.equal(lifecycle.active(), false);
|
||||
});
|
||||
|
||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||
const controller = new AbortController();
|
||||
const observedSignals = [];
|
||||
const queuedVerify = createAbortFencedBuildVerifier(
|
||||
controller.signal,
|
||||
(signal) => observedSignals.push(signal),
|
||||
);
|
||||
|
||||
queuedVerify();
|
||||
// stopBuildMonitor aborts the locally captured controller when the last
|
||||
// mounted viewer unsubscribes. A browser callback already queued before the
|
||||
// interval/listener removal can still run once, but cannot start a fetch.
|
||||
controller.abort();
|
||||
queuedVerify();
|
||||
|
||||
assert.deepEqual(observedSignals, [controller.signal]);
|
||||
assert.equal(observedSignals[0].aborted, true);
|
||||
});
|
||||
|
||||
test("diagnostic body and build id retain exact document/viewer/build lineage", () => {
|
||||
const eventLineage = lineage("00000000-0000-4000-8000-000000000041", 9);
|
||||
assert.deepEqual(
|
||||
liveViewerDiagnosticBody(
|
||||
{ eventCode: "live_receiver_recovered", streamId: "acquisition-42" },
|
||||
eventLineage,
|
||||
),
|
||||
{
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v2",
|
||||
event_code: "live_receiver_recovered",
|
||||
ui_build_id: "/assets/index-abcdefgh.js",
|
||||
document_instance_id: "00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id: "00000000-0000-4000-8000-000000000041",
|
||||
lifecycle_generation: 9,
|
||||
stream_id: "acquisition-42",
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
uiBuildIdFromModuleScripts(
|
||||
["https://mission.local/assets/index-dT7dN-y4.js"],
|
||||
"https://mission.local/park",
|
||||
),
|
||||
"/assets/index-dT7dN-y4.js",
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let claimExclusiveLiveViewer;
|
||||
let createRecordedOpenWatchdog;
|
||||
let createReentrantViewerDisposer;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
@@ -17,7 +19,9 @@ before(async () => {
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
claimExclusiveLiveViewer,
|
||||
createRecordedOpenWatchdog,
|
||||
createReentrantViewerDisposer,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
@@ -118,6 +122,45 @@ test("complete recorded admission clears its watchdog", () => {
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("deferred viewer start cannot reopen after stale unmount", async () => {
|
||||
let resolveStart;
|
||||
const start = new Promise((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
let disposed = false;
|
||||
let cleanupCount = 0;
|
||||
let closeCount = 0;
|
||||
let stopCount = 0;
|
||||
const diagnostics = [];
|
||||
const disposeViewer = createReentrantViewerDisposer(
|
||||
() => {
|
||||
cleanupCount += 1;
|
||||
},
|
||||
() => {
|
||||
closeCount += 1;
|
||||
stopCount += 1;
|
||||
},
|
||||
);
|
||||
const pendingMount = (async () => {
|
||||
await start;
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
return;
|
||||
}
|
||||
diagnostics.push("admitted");
|
||||
})();
|
||||
|
||||
disposed = true;
|
||||
disposeViewer();
|
||||
resolveStart();
|
||||
await pendingMount;
|
||||
|
||||
assert.equal(cleanupCount, 1);
|
||||
assert.equal(closeCount, 2);
|
||||
assert.equal(stopCount, 2);
|
||||
assert.deepEqual(diagnostics, []);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
@@ -129,7 +172,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
|
||||
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -145,12 +188,31 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
);
|
||||
});
|
||||
|
||||
test("one live document owns one native Rerun receiver", async () => {
|
||||
const releases = [];
|
||||
const releaseFirstClaim = claimExclusiveLiveViewer(() => releases.push("first"));
|
||||
const releaseSecondClaim = claimExclusiveLiveViewer(() => releases.push("second"));
|
||||
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseFirstClaim();
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseSecondClaim();
|
||||
|
||||
const releaseThirdClaim = claimExclusiveLiveViewer(() => releases.push("third"));
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseThirdClaim();
|
||||
});
|
||||
|
||||
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /followLive=\{!recordedReplay && streamActive\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
|
||||
@@ -42,6 +42,113 @@ function snapshot(revision, generation, phase = "streaming", sessionId = "device
|
||||
};
|
||||
}
|
||||
|
||||
function stampedSnapshot({
|
||||
runtimeStartedAt = "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs = "1000000",
|
||||
runtimeId = "runtime-a",
|
||||
snapshotRevision = 1,
|
||||
cameraRevision = snapshotRevision,
|
||||
generation = 1,
|
||||
} = {}) {
|
||||
return {
|
||||
...snapshot(cameraRevision, generation),
|
||||
snapshot_runtime_started_at_utc: runtimeStartedAt,
|
||||
snapshot_runtime_started_monotonic_ns: runtimeStartedMonotonicNs,
|
||||
snapshot_runtime_id: runtimeId,
|
||||
snapshot_revision: snapshotRevision,
|
||||
};
|
||||
}
|
||||
|
||||
test("uses the process snapshot revision before camera-local counters", () => {
|
||||
const current = stampedSnapshot({ snapshotRevision: 8, cameraRevision: 2 });
|
||||
const stale = stampedSnapshot({ snapshotRevision: 7, cameraRevision: 99 });
|
||||
const newer = stampedSnapshot({ snapshotRevision: 9, cameraRevision: 1 });
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, stale), current);
|
||||
assert.equal(selectMonotonicXgridsState(current, newer), newer);
|
||||
});
|
||||
|
||||
test("accepts a newer runtime and rejects a delayed snapshot from the old runtime", () => {
|
||||
const oldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs: "1000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 300,
|
||||
});
|
||||
const newRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:05:00Z",
|
||||
runtimeStartedMonotonicNs: "2000000",
|
||||
runtimeId: "runtime-new",
|
||||
snapshotRevision: 1,
|
||||
});
|
||||
const delayedOldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs: "1000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 301,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
|
||||
assert.equal(
|
||||
selectMonotonicXgridsState(newRuntime, delayedOldRuntime),
|
||||
newRuntime,
|
||||
);
|
||||
});
|
||||
|
||||
test("orders restarts by monotonic time even when UTC moves backwards", () => {
|
||||
const oldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:05:00Z",
|
||||
runtimeStartedMonotonicNs: "2000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 900,
|
||||
});
|
||||
const newRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T09:55:00Z",
|
||||
runtimeStartedMonotonicNs: "3000000",
|
||||
runtimeId: "runtime-new",
|
||||
snapshotRevision: 1,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
|
||||
assert.equal(selectMonotonicXgridsState(newRuntime, oldRuntime), newRuntime);
|
||||
});
|
||||
|
||||
test("orders two runtimes sharing the same UTC millisecond", () => {
|
||||
const first = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "4000000",
|
||||
runtimeId: "runtime-first",
|
||||
});
|
||||
const second = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "4000001",
|
||||
runtimeId: "runtime-second",
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(first, second), second);
|
||||
});
|
||||
|
||||
test("a malformed monotonic stamp cannot replace valid runtime authority", () => {
|
||||
const current = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "5000000",
|
||||
runtimeId: "runtime-current",
|
||||
});
|
||||
const malformed = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T11:00:00Z",
|
||||
runtimeStartedMonotonicNs: "not-a-number",
|
||||
runtimeId: "runtime-malformed",
|
||||
snapshotRevision: 9999,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, malformed), current);
|
||||
});
|
||||
|
||||
test("does not let an unstamped legacy response replace stamped authority", () => {
|
||||
const current = stampedSnapshot({ snapshotRevision: 8 });
|
||||
const legacy = snapshot(99, 99);
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, legacy), current);
|
||||
assert.equal(selectMonotonicXgridsState(legacy, current), current);
|
||||
});
|
||||
|
||||
test("accepts the first camera preview snapshot", () => {
|
||||
const incoming = snapshot(1, 1);
|
||||
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
|
||||
|
||||
@@ -13,6 +13,9 @@ let projectObservationLayoutSnapshot;
|
||||
let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let admitLiveDefaultPresentations;
|
||||
let automaticLivePresentationIdentity;
|
||||
let livePresentationCloseFence;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
@@ -33,6 +36,9 @@ before(async () => {
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({
|
||||
admitLiveDefaultPresentations,
|
||||
automaticLivePresentationIdentity,
|
||||
livePresentationCloseFence,
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
@@ -213,6 +219,95 @@ test("opening a recorded catalog reveals its sealed cameras beside the point clo
|
||||
);
|
||||
});
|
||||
|
||||
test("a sequential live acquisition re-arms the same camera without reopening a deliberate close", () => {
|
||||
const pointCloud = {
|
||||
id: "k1:sensor.lidar.primary",
|
||||
sourceId: "sensor.lidar.primary",
|
||||
modality: "point-cloud",
|
||||
availability: "streaming",
|
||||
transport: "rerun-grpc",
|
||||
previewUrl: "grpc://127.0.0.1:9876/proxy",
|
||||
delivery: null,
|
||||
activation: null,
|
||||
binding: {
|
||||
deviceId: "k1-a",
|
||||
deviceSessionId: "device-session-reused",
|
||||
acquisitionId: "acquisition-a",
|
||||
},
|
||||
capabilities: { defaultVisible: true, overlay: false },
|
||||
};
|
||||
const camera = (acquisitionId, deliveryId) => ({
|
||||
id: "k1:sensor.camera.right",
|
||||
sourceId: "sensor.camera.right",
|
||||
modality: "video",
|
||||
availability: "streaming",
|
||||
transport: "websocket",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: deliveryId,
|
||||
kind: "mse-fmp4-websocket",
|
||||
url: "/camera-preview/reused",
|
||||
mediaType: 'video/mp4; codecs="avc1.641028"',
|
||||
},
|
||||
activation: {
|
||||
groupId: "k1:device-session-reused:camera.preview.decoder",
|
||||
maxActive: 1,
|
||||
selected: true,
|
||||
controllable: true,
|
||||
},
|
||||
binding: {
|
||||
deviceId: "k1-a",
|
||||
deviceSessionId: "device-session-reused",
|
||||
acquisitionId,
|
||||
},
|
||||
capabilities: { defaultVisible: true, overlay: true },
|
||||
});
|
||||
|
||||
const firstCamera = camera("acquisition-a", "camera-preview-2");
|
||||
const first = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[pointCloud, firstCamera],
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.deepEqual(first.visibleIds, [pointCloud.id, firstCamera.id]);
|
||||
assert.deepEqual(first.admittedIdentities, [
|
||||
automaticLivePresentationIdentity(firstCamera),
|
||||
]);
|
||||
|
||||
const sameAcquisitionNewDelivery = camera("acquisition-a", "camera-preview-3");
|
||||
const closedInFirstAcquisition = new Set([
|
||||
livePresentationCloseFence(firstCamera),
|
||||
]);
|
||||
const afterDeliberateClose = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[pointCloud, sameAcquisitionNewDelivery],
|
||||
new Set(first.admittedIdentities),
|
||||
closedInFirstAcquisition,
|
||||
);
|
||||
assert.deepEqual(afterDeliberateClose.visibleIds, [pointCloud.id]);
|
||||
assert.deepEqual(afterDeliberateClose.admittedIdentities, []);
|
||||
|
||||
const nextAcquisitionSameDelivery = camera("acquisition-b", "camera-preview-3");
|
||||
assert.notEqual(
|
||||
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
|
||||
automaticLivePresentationIdentity(sameAcquisitionNewDelivery),
|
||||
);
|
||||
const second = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[
|
||||
{ ...pointCloud, binding: { ...pointCloud.binding, acquisitionId: "acquisition-b" } },
|
||||
nextAcquisitionSameDelivery,
|
||||
],
|
||||
new Set(first.admittedIdentities),
|
||||
closedInFirstAcquisition,
|
||||
);
|
||||
assert.deepEqual(second.visibleIds, [pointCloud.id, nextAcquisitionSameDelivery.id]);
|
||||
assert.deepEqual(second.admittedIdentities, [
|
||||
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
|
||||
]);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
|
||||
Reference in New Issue
Block a user