Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
#ifdef WORKBENCH
|
||||
// Disposable native fixture only. Keep native serialization, profile storage,
|
||||
// PlayerManager occupancy and production constructor/Finish; replace transport.
|
||||
modded class ERR_Telemetry
|
||||
{
|
||||
protected override void Pulse() {}
|
||||
bool ERR_TestCheck(bool valid, string detail)
|
||||
{
|
||||
if (valid) Print("ERR SMOKE PASS: telemetry " + detail);
|
||||
else Print("ERR SMOKE FAIL: telemetry " + detail, LogLevel.ERROR);
|
||||
return valid;
|
||||
}
|
||||
int ERR_TestFinalCount(string mission)
|
||||
{
|
||||
int count;
|
||||
foreach (string payload : m_State.pending)
|
||||
{
|
||||
JsonLoadContext json = new JsonLoadContext(); string id, type, outcome; int phase;
|
||||
if (!json.LoadFromString(payload)) return -1;
|
||||
json.ReadValue("missionId", id); json.ReadValue("type", type);
|
||||
if (id != mission || type != "mission_finished") continue;
|
||||
json.ReadValue("outcome", outcome); json.ReadValue("phase", phase);
|
||||
if (outcome != "defeat" || phase != ERR_Phase.DEFEAT) return -1;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
void ERR_TestFreeze()
|
||||
{
|
||||
m_Closed = true;
|
||||
GetGame().GetCallqueue().Remove(Pulse);
|
||||
}
|
||||
bool ERR_TestOccupiedRecovery()
|
||||
{
|
||||
int connected = GetGame().GetPlayerManager().GetPlayerCount();
|
||||
PrintFormat("ERR TELEMETRY NATIVE: connected=%1 mission=%2", connected, m_MissionId);
|
||||
if (!ERR_TestCheck(Enabled() && connected > 0, "uses actual connected native player")) return false;
|
||||
Checkpoint();
|
||||
ERR_TelemetryState disk;
|
||||
if (!ERR_TestCheck(ReadState(STATE, disk) && !disk.restartFailure.IsEmpty() && disk.previousOutcome == "unfinished", "occupied checkpoint round-trips through native JSON and profile storage")) return false;
|
||||
if (!ERR_TestCheck(ERR_TestFinalCount(m_MissionId) == 0, "live checkpoint is not queued as a failure")) return false;
|
||||
// Constructor recovery from persisted unfinished state, as after a forced stop.
|
||||
ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode);
|
||||
bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1;
|
||||
recovered.ERR_TestFreeze();
|
||||
if (!ERR_TestCheck(recoveredOK, "fresh service recovers exactly one defeat for the old mission")) return false;
|
||||
// Restore the live service's state for the real EndGameMode test below.
|
||||
if (!ERR_TestCheck(Save(), "restore original live checkpoint for native shutdown")) return false;
|
||||
return true;
|
||||
}
|
||||
bool ERR_TestFinished()
|
||||
{
|
||||
ERR_TelemetryState disk;
|
||||
if (!ERR_TestCheck(m_Ended && m_State.previousOutcome == "defeat" && m_State.restartFailure.IsEmpty() && ERR_TestFinalCount(m_MissionId) == 1, "actual native end records exactly one occupied failure")) return false;
|
||||
if (!ERR_TestCheck(ReadState(STATE, disk) && disk.previousOutcome == "defeat" && disk.restartFailure.IsEmpty(), "completed result persisted and candidate cleared")) return false;
|
||||
ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode);
|
||||
bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1;
|
||||
recovered.ERR_TestFreeze();
|
||||
if (!ERR_TestCheck(recoveredOK, "reopening completed state does not duplicate the failure")) return false;
|
||||
return Save();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,43 @@
|
||||
# Native telemetry restart test
|
||||
|
||||
Run from the repository root with a disposable existing Proton test prefix and
|
||||
the installed authorized dependency tree:
|
||||
|
||||
```sh
|
||||
python3 tests/native/telemetry_restart/run.py \
|
||||
--work .local/restart-failure/verified \
|
||||
--compat .local/rhs-19-work/compat \
|
||||
--external-addons '/home/jakub/.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons'
|
||||
```
|
||||
|
||||
The work directory must be new. The runner copies the current addon and adds the
|
||||
fixture only to that copy. It reuses the native preparation-stage shutdown probe
|
||||
so extraction placement is not a prerequisite. It then explicitly stops and
|
||||
relaunches its owned Workbench process with the same
|
||||
private profile. Workbench hosting does not automatically reload this fixture.
|
||||
Two rounds use the actual native PlayerManager connection count and EndGameMode
|
||||
callback. Production reload behavior is not changed.
|
||||
|
||||
The fixture checks native JSON/profile round-trips, an occupied checkpoint not
|
||||
being submitted while the round is live, constructor recovery from unfinished
|
||||
state, a single persisted defeat on shutdown, and completed-state reopening
|
||||
without another defeat. Forced-stop recovery is simulated by constructing a new
|
||||
service from the saved unfinished checkpoint; this is not an AMP process-kill
|
||||
or multiplayer dedicated-server test. Empty occupancy and completion variants
|
||||
also have production-method tests in `tests/test_telemetry.py`.
|
||||
|
||||
The copied test class replaces only the telemetry dispatch tick, preventing
|
||||
network submissions. A reserved `.invalid` endpoint is an additional safeguard.
|
||||
The fixture restores the original service's state after each recovery probe.
|
||||
No test code or test results are included in the published addon.
|
||||
|
||||
Native evidence is retained under the work directory's `profile/logs`, including
|
||||
source hashes and cleanup verification. Queued payloads are in
|
||||
`profile/profile/EscapistsTelemetryState.json` and can be checked with the
|
||||
receiver's `validateEvent` function.
|
||||
|
||||
The general native report also grades optional asset and backend errors. This
|
||||
fixture retains that complete report as `telemetry-check.json`, while its result
|
||||
requires completed telemetry assertions, no script/runtime failures, unchanged
|
||||
inputs, confirmed process cleanup and valid queued results. It does not certify
|
||||
Rally/RHS/WCS assets, DLC ownership or the rest of the mission.
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run isolated native occupancy, JSON persistence, recovery and shutdown checks."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--work', type=Path, required=True, help='New disposable directory; must not exist')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Existing private test Proton prefix')
|
||||
p.add_argument('--external-addons', type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
work = a.work.resolve()
|
||||
if work.exists():
|
||||
p.error('--work must not exist; preserve evidence from earlier runs')
|
||||
work.mkdir(parents=True)
|
||||
source, profile = work/'source', work/'profile'
|
||||
for name in ['addons', 'tools']:
|
||||
shutil.copytree(ROOT/name, source/name)
|
||||
(source/'docs').mkdir()
|
||||
shutil.copy2(ROOT/'docs/external-addons.json', source/'docs/external-addons.json')
|
||||
|
||||
def replace(path, before, after):
|
||||
text = path.read_text()
|
||||
if text.count(before) != 1:
|
||||
raise ValueError(f'Fixture insertion point changed in {path.name}')
|
||||
path.write_text(text.replace(before, after))
|
||||
|
||||
scripts = source/'addons/core/Scripts/Game/ERR/00_Core'
|
||||
shutil.copy2(Path(__file__).with_name('ERR_TelemetryCheckpointTest.c'), scripts/'ERR_TelemetryCheckpointTest.c')
|
||||
# Modded classes can only access fields/methods of earlier layers. Test accessors
|
||||
# follow telemetry; the existing native-end fixture is moved after that layer.
|
||||
replace(scripts/'ERR_TelemetryLifecycle.c', ' void ERR_RefreshTelemetry()',
|
||||
' bool ERR_TestTelemetryBefore() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestOccupiedRecovery(); }\n'
|
||||
' bool ERR_TestTelemetryAfter() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestFinished(); }\n'
|
||||
' void ERR_RefreshTelemetry()')
|
||||
probe = scripts/'ERR_NativeEndProbe.c'
|
||||
replace(probe, 'm_ERREndData = SCR_GameModeEndData.CreateSimple',
|
||||
'if (!ERR_EndCheck(ERR_TestTelemetryBefore(), "occupied telemetry snapshot and recovery")) return;\n'
|
||||
' m_ERREndData = SCR_GameModeEndData.CreateSimple')
|
||||
replace(probe, 'EndGameMode(m_ERREndData);',
|
||||
'EndGameMode(m_ERREndData);\n'
|
||||
' if (!ERR_EndCheck(ERR_TestTelemetryAfter(), "native shutdown records one defeat")) return;')
|
||||
probe.rename(scripts/'ERR_ZTelemetryNativeEndProbe.c')
|
||||
runner = source/'tools/headless_workbench.py'
|
||||
replace(runner, 'if a.roadblock_probe_count is not None: command',
|
||||
"command += ['-errTelemetryTest', '1']\nif a.roadblock_probe_count is not None: command")
|
||||
# Use two explicit process launches: Workbench need not auto-reload a hosted game.
|
||||
replace(runner, "a.project != 'core' or a.rounds != 2 or a.timeout < 120", "a.project != 'core' or a.rounds != 1 or a.timeout < 120")
|
||||
replace(runner, "if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('native-end fixture paths must not preexist; preserve prior evidence before retrying')",
|
||||
'# This isolated fixture intentionally preserves the first instance marker.')
|
||||
(profile/'profile').mkdir(parents=True)
|
||||
(profile/'profile/EscapistsTelemetry.json').write_text(
|
||||
'{"endpoint":"https://telemetry.invalid/api/mission-events",'
|
||||
'"token":"local-test-only","serverName":"Isolated restart test"}\n')
|
||||
command = [sys.executable, str(runner), '--compat', str(a.compat.resolve()),
|
||||
'--profile', str(profile), '--external-addons', str(a.external_addons.resolve()),
|
||||
'--smoke', 'native-end-preparing', '--project', 'core', '--rounds', '1', '--timeout', '120']
|
||||
sys.path.insert(0, str(ROOT/'tools'))
|
||||
from native_report import analyze
|
||||
for _ in range(2):
|
||||
old_logs = set((profile/'logs').glob('logs_*'))
|
||||
result = subprocess.run(command, check=False)
|
||||
new_logs = set((profile/'logs').glob('logs_*')) - old_logs
|
||||
assert len(new_logs) == 1, 'Expected exactly one fresh native session'
|
||||
session = new_logs.pop()
|
||||
evidence = json.loads((session/'runner.json').read_text())
|
||||
report = analyze((session/'console.log').read_text(errors='replace'), session/'console.log')
|
||||
assert result.returncode in (0, 1)
|
||||
assert evidence['result'] == 'smoke-completed' and evidence['cleanup_confirmed']
|
||||
assert evidence['source_unchanged_after_run'] and evidence['external_addons_unchanged_after_run']
|
||||
assert report['smoke_completions'] == 1 and not report['script_or_runtime_failures']
|
||||
assert not report['missing_end_screen']
|
||||
# The generic runner also grades all optional asset/backend errors. Preserve
|
||||
# its complete report; this fixture grades telemetry, not DLC/asset integrity.
|
||||
report['telemetry_assertions_passed'] = True
|
||||
(session/'telemetry-check.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
if report['engine_errors']:
|
||||
print(f"Telemetry checks passed; {len(report['engine_errors'])} asset/backend errors retained in {session/'telemetry-check.json'}", flush=True)
|
||||
state_path = profile/'profile/EscapistsTelemetryState.json'
|
||||
events = [json.loads(payload) for payload in json.loads(state_path.read_text())['state']['pending']]
|
||||
finished = [event for event in events if event['type'] == 'mission_finished']
|
||||
assert len(finished) == 2 and len({event['missionId'] for event in finished}) == 2
|
||||
assert all(event['outcome'] == 'defeat' and event['phase'] == 6 and event['connectedPlayers'] > 0 for event in finished)
|
||||
restarts = [event for event in events if event['type'] == 'mission_restarted']
|
||||
assert len(restarts) == 1 and restarts[0]['previousOutcome'] == 'defeat'
|
||||
assert restarts[0]['previousMissionId'] in {event['missionId'] for event in finished}
|
||||
subprocess.run(['node', '--input-type=module', '-e',
|
||||
"import {readFileSync} from 'node:fs'; import {validateEvent} from "
|
||||
+ json.dumps((ROOT/'services/telemetry-worker/src/validation.js').as_uri()) + ";"
|
||||
+ "const {state}=JSON.parse(readFileSync(process.argv[1],'utf8'));"
|
||||
+ "for(const payload of state.pending) validateEvent(JSON.parse(payload));", str(state_path)], check=True)
|
||||
print('PASS: two native process instances, real occupied shutdowns, durable recovery, no duplicate results and receiver-valid native payloads')
|
||||
Reference in New Issue
Block a user