mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* fix(web): keep Routines tab after engine v1 → v2 upgrade (#2982) Users upgrading from a v1 install (e.g. 0.24.0 → 0.26.0) lost the UI affordance to view or manage existing routines: `applyEngineModeToTabs()` and `applyEngineModeUi()` unconditionally hid the v1-only Routines tab whenever ENGINE_V2 was enabled, even though the routines were still in the database and the API still served them. The fix adds a `userHasLegacyRoutines` flag, populated from `/api/routines/summary` on first gateway-status poll. The Routines tab stays visible (and `#/routines/<id>` still resolves to the legacy detail view) when the user has any v1 routines. Also fixes a wire-contract drift in `gateway-tee.js`: it read `data.engine_v2` for the activity store and `data.engine_v2_enabled` for the global, with `applyEngineModeUi()` running before the global was set. Per `.claude/rules/types.md` ("Wire-contract field naming"), the duplicate `engine_v2` field is removed from `GatewayStatusResponse`; the JS now reads the single canonical name once and sets the global before any UI helper consults it. * fix(web): address PR #2992 review notes — race guard, dedup, post-delete refresh Three review-driven hardening tweaks plus expanded Playwright coverage, all on the same #2982 fix: - gateway-tee.js: flip `engineModeApplied = true` synchronously so a second status poll firing while the first refresh is still in flight cannot kick off a duplicate `/api/routines/summary` request. The trailing `.then()` still runs on fetch failure (the `.catch()` chain resolves to undefined), so the UI still settles. - projects.js: route the routines-tab visibility branch through `shouldHideRoutinesTab()` instead of duplicating the predicate inline. Single source of truth for the rule. - routines.js: refresh `userHasLegacyRoutines` after a successful `deleteRoutine` so the v2 user who just removed their last legacy routine sees the tab fall back to hidden without a page reload. Playwright coverage grew from 5 to 11 cases: route-mocked summary, zero-total clears the flag, fetch failure preserves the prior value, post-delete refresh hides the tab, dual back-to-back first polls fan out only one summary fetch, and `restoreFromHash` routes correctly when legacy data exists.
This commit is contained in:
@@ -121,6 +121,10 @@ const GENERATED_IMAGE_THREAD_CACHE_CAP = 20;
|
|||||||
const GENERATED_IMAGES_PER_THREAD_CAP = 8;
|
const GENERATED_IMAGES_PER_THREAD_CAP = 8;
|
||||||
let engineV2Enabled = false;
|
let engineV2Enabled = false;
|
||||||
let engineModeApplied = false;
|
let engineModeApplied = false;
|
||||||
|
// True when the user has at least one v1 routine in the database. Set
|
||||||
|
// from /api/routines/summary so the Routines tab stays visible after
|
||||||
|
// an engine v1 → v2 upgrade for users with pre-existing routines (#2982).
|
||||||
|
let userHasLegacyRoutines = false;
|
||||||
let currentMissionData = null;
|
let currentMissionData = null;
|
||||||
let currentEngineThreadDetail = null;
|
let currentEngineThreadDetail = null;
|
||||||
let currentMissionList = [];
|
let currentMissionList = [];
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ function startGatewayStatusPolling() {
|
|||||||
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
|
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets userHasLegacyRoutines from /api/routines/summary. Resolves
|
||||||
|
// regardless of fetch outcome so the caller's chained UI work always
|
||||||
|
// runs. A failure leaves the global at its current value (default false),
|
||||||
|
// which matches the pre-fix behaviour for v2 deployments.
|
||||||
|
function refreshLegacyRoutinesPresence() {
|
||||||
|
return apiFetch('/api/routines/summary').then(function(s) {
|
||||||
|
userHasLegacyRoutines = !!(s && (s.total || 0) > 0);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
function formatTokenCount(n) {
|
function formatTokenCount(n) {
|
||||||
if (n == null || n === 0) return '0';
|
if (n == null || n === 0) return '0';
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||||
@@ -30,21 +40,39 @@ function shortModelName(model) {
|
|||||||
|
|
||||||
function fetchGatewayStatus() {
|
function fetchGatewayStatus() {
|
||||||
apiFetch('/api/gateway/status').then(function(data) {
|
apiFetch('/api/gateway/status').then(function(data) {
|
||||||
activeWorkStore.setEngineV2Enabled(!!data.engine_v2);
|
// Single canonical wire field: `engine_v2_enabled`. Reading two
|
||||||
applyEngineModeUi();
|
// different field names from the same response was a divergence
|
||||||
|
// hazard called out in .claude/rules/types.md and triggered the
|
||||||
|
// ordering bug behind #2982.
|
||||||
|
var enabled = !!data.engine_v2_enabled;
|
||||||
|
|
||||||
|
// Apply engine v2 / v1 tab visibility once. Set the global before
|
||||||
|
// any UI helper reads it. The flag flips synchronously so that a
|
||||||
|
// second status poll firing while the first refresh is still in
|
||||||
|
// flight does not kick off a duplicate /api/routines/summary
|
||||||
|
// request. refreshLegacyRoutinesPresence swallows fetch errors, so
|
||||||
|
// the trailing .then() still runs on failure with
|
||||||
|
// userHasLegacyRoutines = false (the safe default).
|
||||||
|
if (!engineModeApplied) {
|
||||||
|
engineModeApplied = true;
|
||||||
|
engineV2Enabled = enabled;
|
||||||
|
// Refresh legacy-routine count once on first status so v1 users
|
||||||
|
// upgrading to v2 keep the Routines tab affordance (#2982).
|
||||||
|
refreshLegacyRoutinesPresence().then(function() {
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyEngineModeUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeWorkStore.setEngineV2Enabled(enabled);
|
||||||
refreshPersistentActivityBar();
|
refreshPersistentActivityBar();
|
||||||
|
|
||||||
// Update restart button visibility
|
// Update restart button visibility
|
||||||
restartEnabled = data.restart_enabled || false;
|
restartEnabled = data.restart_enabled || false;
|
||||||
updateRestartButtonVisibility();
|
updateRestartButtonVisibility();
|
||||||
|
|
||||||
// Apply engine v2 / v1 tab visibility once.
|
|
||||||
if (!engineModeApplied) {
|
|
||||||
engineV2Enabled = !!data.engine_v2_enabled;
|
|
||||||
applyEngineModeToTabs();
|
|
||||||
engineModeApplied = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var popover = document.getElementById('gateway-popover');
|
var popover = document.getElementById('gateway-popover');
|
||||||
var html = '';
|
var html = '';
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,16 @@ function parseHash() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldHideRoutinesTab() {
|
||||||
|
// The Routines tab belongs to engine v1. When v2 is on, hide it — UNLESS
|
||||||
|
// the user has existing v1 routines from a pre-v2 install. Without this
|
||||||
|
// affordance, an upgrade silently strips access to data the API still
|
||||||
|
// serves (#2982).
|
||||||
|
return engineV2Enabled && !userHasLegacyRoutines;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTabForEngineMode(tab) {
|
function normalizeTabForEngineMode(tab) {
|
||||||
if (engineV2Enabled && tab === 'routines') {
|
if (shouldHideRoutinesTab() && tab === 'routines') {
|
||||||
return 'missions';
|
return 'missions';
|
||||||
}
|
}
|
||||||
return tab;
|
return tab;
|
||||||
@@ -61,13 +69,14 @@ function normalizeTabForEngineMode(tab) {
|
|||||||
function applyEngineModeUi() {
|
function applyEngineModeUi() {
|
||||||
var routinesTab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
var routinesTab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
var routinesPanel = document.getElementById('tab-routines');
|
var routinesPanel = document.getElementById('tab-routines');
|
||||||
|
var hideRoutines = shouldHideRoutinesTab();
|
||||||
if (routinesTab) {
|
if (routinesTab) {
|
||||||
routinesTab.style.display = engineV2Enabled ? 'none' : '';
|
routinesTab.style.display = hideRoutines ? 'none' : '';
|
||||||
}
|
}
|
||||||
if (routinesPanel && engineV2Enabled && currentTab !== 'routines') {
|
if (routinesPanel && hideRoutines && currentTab !== 'routines') {
|
||||||
routinesPanel.classList.remove('active');
|
routinesPanel.classList.remove('active');
|
||||||
}
|
}
|
||||||
if (engineV2Enabled && currentTab === 'routines') {
|
if (hideRoutines && currentTab === 'routines') {
|
||||||
switchTab('missions');
|
switchTab('missions');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +113,7 @@ function restoreFromHash() {
|
|||||||
openJobDetail(state.detail);
|
openJobDetail(state.detail);
|
||||||
break;
|
break;
|
||||||
case 'routines':
|
case 'routines':
|
||||||
if (engineV2Enabled) {
|
if (shouldHideRoutinesTab()) {
|
||||||
switchTab('missions');
|
switchTab('missions');
|
||||||
} else {
|
} else {
|
||||||
openRoutineDetail(state.detail);
|
openRoutineDetail(state.detail);
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ function applyEngineModeToTabs() {
|
|||||||
document.querySelectorAll('.tab-bar [data-v2-only]').forEach(function(el) {
|
document.querySelectorAll('.tab-bar [data-v2-only]').forEach(function(el) {
|
||||||
el.style.display = engineV2Enabled ? '' : 'none';
|
el.style.display = engineV2Enabled ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
// The Routines tab is the only v1-only tab today and stays visible
|
||||||
|
// when the user still has legacy routines (#2982). Other v1-only
|
||||||
|
// markers, if added later, follow the engine flag. shouldHideRoutinesTab
|
||||||
|
// is the single source of truth for the routines-visibility rule —
|
||||||
|
// duplicating its logic here was how #2574 / #2665 originally drifted.
|
||||||
document.querySelectorAll('.tab-bar [data-v1-only]').forEach(function(el) {
|
document.querySelectorAll('.tab-bar [data-v1-only]').forEach(function(el) {
|
||||||
el.style.display = engineV2Enabled ? 'none' : '';
|
var isRoutinesTab = el.getAttribute('data-tab-role') === 'routines';
|
||||||
|
var hide = isRoutinesTab ? shouldHideRoutinesTab() : engineV2Enabled;
|
||||||
|
el.style.display = hide ? 'none' : '';
|
||||||
});
|
});
|
||||||
var activeBtn = document.querySelector('.tab-bar button[data-tab].active');
|
var activeBtn = document.querySelector('.tab-bar button[data-tab].active');
|
||||||
if (activeBtn && activeBtn.style.display === 'none') switchTab('chat');
|
if (activeBtn && activeBtn.style.display === 'none') switchTab('chat');
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ function deleteRoutine(id, name) {
|
|||||||
apiFetch('/api/routines/' + id, { method: 'DELETE' })
|
apiFetch('/api/routines/' + id, { method: 'DELETE' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast(I18n.t('routines.deleted'), 'success');
|
showToast(I18n.t('routines.deleted'), 'success');
|
||||||
|
// Re-check legacy routine count so the v2 user who just deleted
|
||||||
|
// their last v1 routine sees the tab fall back to hidden without
|
||||||
|
// a page reload (#2982).
|
||||||
|
refreshLegacyRoutinesPresence().then(function() {
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
});
|
||||||
if (currentRoutineId === id) closeRoutineDetail();
|
if (currentRoutineId === id) closeRoutineDetail();
|
||||||
else loadRoutines();
|
else loadRoutines();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ pub(crate) struct GatewayStatusResponse {
|
|||||||
ws_connections: u64,
|
ws_connections: u64,
|
||||||
total_connections: u64,
|
total_connections: u64,
|
||||||
uptime_secs: u64,
|
uptime_secs: u64,
|
||||||
engine_v2: bool,
|
|
||||||
restart_enabled: bool,
|
restart_enabled: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
daily_cost: Option<String>,
|
daily_cost: Option<String>,
|
||||||
@@ -105,7 +104,6 @@ pub(crate) async fn gateway_status_handler(
|
|||||||
ws_connections,
|
ws_connections,
|
||||||
total_connections: sse_connections + ws_connections,
|
total_connections: sse_connections + ws_connections,
|
||||||
uptime_secs,
|
uptime_secs,
|
||||||
engine_v2: crate::bridge::is_engine_v2_enabled(),
|
|
||||||
restart_enabled,
|
restart_enabled,
|
||||||
daily_cost,
|
daily_cost,
|
||||||
actions_this_hour,
|
actions_this_hour,
|
||||||
|
|||||||
311
tests/e2e/scenarios/test_routines_tab_after_v2_upgrade.py
Normal file
311
tests/e2e/scenarios/test_routines_tab_after_v2_upgrade.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""Regression for #2982: Routines tab visibility after engine v1 → v2 upgrade.
|
||||||
|
|
||||||
|
The bug: when ENGINE_V2 is on, `applyEngineModeToTabs()` and
|
||||||
|
`applyEngineModeUi()` unconditionally hid the v1-only Routines tab.
|
||||||
|
Users who upgraded from a v1 install (e.g. 0.24.0 → 0.26.0) lost the UI
|
||||||
|
affordance to view or manage their existing routines, even though the
|
||||||
|
routines were still in the DB and the API still served them.
|
||||||
|
|
||||||
|
The fix carries a `userHasLegacyRoutines` flag — when the flag is set,
|
||||||
|
the Routines tab stays visible even with engine v2 enabled. These tests
|
||||||
|
drive the JS helpers directly via `page.evaluate()` because the e2e
|
||||||
|
harness ships with `ROUTINES_ENABLED=false`, so we cannot create a real
|
||||||
|
routine in fixture setup. See `tests/e2e/CLAUDE.md` → "Environment
|
||||||
|
passed to ironclaw in tests".
|
||||||
|
|
||||||
|
Some scenarios route-mock `/api/routines/summary` so we can drive the
|
||||||
|
real `fetchGatewayStatus` / `refreshLegacyRoutinesPresence` call sites
|
||||||
|
end-to-end and exercise the testing-rule "test through the caller, not
|
||||||
|
just the helper".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper-level coverage (each branch of the predicate) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routines_tab_visible_when_user_has_legacy_routines(page):
|
||||||
|
"""v2 enabled + legacy routines → Routines tab stays visible."""
|
||||||
|
|
||||||
|
visible = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = true;
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
|
return tab && tab.style.display !== 'none';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert visible, "Routines tab must stay visible when legacy routines exist"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routines_tab_hidden_in_v2_with_no_legacy_routines(page):
|
||||||
|
"""v2 enabled + no legacy routines → Routines tab hidden (existing v2 behavior)."""
|
||||||
|
|
||||||
|
hidden = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = false;
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
|
return tab && tab.style.display === 'none';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert hidden, "Routines tab must be hidden when v2 is on and user has no routines"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routines_tab_visible_in_v1(page):
|
||||||
|
"""Engine v1 → Routines tab visible regardless of routine count."""
|
||||||
|
|
||||||
|
visible = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = false;
|
||||||
|
userHasLegacyRoutines = false;
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
|
return tab && tab.style.display !== 'none';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert visible, "Routines tab must always be visible in engine v1 mode"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routines_hash_route_routes_to_routines_when_legacy_exists(page):
|
||||||
|
"""`#/routines/<id>` → opens routine detail when legacy routines exist (#2982)."""
|
||||||
|
|
||||||
|
routes_to_routines = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = true;
|
||||||
|
return shouldHideRoutinesTab() === false
|
||||||
|
&& normalizeTabForEngineMode('routines') === 'routines';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert routes_to_routines, (
|
||||||
|
"`routines` hash must resolve to the Routines tab when legacy routines exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_routines_hash_route_falls_back_to_missions_in_pure_v2(page):
|
||||||
|
"""`#/routines` → redirected to Missions when no legacy data (existing v2 behavior)."""
|
||||||
|
|
||||||
|
redirects = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = false;
|
||||||
|
return shouldHideRoutinesTab() === true
|
||||||
|
&& normalizeTabForEngineMode('routines') === 'missions';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert redirects, "Routines hash must redirect to Missions in pure v2 mode"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Caller-level coverage (drives the actual call sites) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _route_summary(page, *, total: int):
|
||||||
|
"""Stub /api/routines/summary to return a controlled total count."""
|
||||||
|
|
||||||
|
async def handler(route):
|
||||||
|
await route.fulfill(
|
||||||
|
status=200,
|
||||||
|
content_type="application/json",
|
||||||
|
body=json.dumps({
|
||||||
|
"total": total,
|
||||||
|
"enabled": total,
|
||||||
|
"disabled": 0,
|
||||||
|
"unverified": 0,
|
||||||
|
"failing": 0,
|
||||||
|
"runs_today": 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.route("**/api/routines/summary", handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_legacy_routines_presence_sets_flag_from_summary(page):
|
||||||
|
"""`/api/routines/summary` total > 0 → userHasLegacyRoutines flips to true (#2982)."""
|
||||||
|
|
||||||
|
await _route_summary(page, total=3)
|
||||||
|
|
||||||
|
flag = await page.evaluate(
|
||||||
|
"""
|
||||||
|
async () => {
|
||||||
|
userHasLegacyRoutines = false;
|
||||||
|
await refreshLegacyRoutinesPresence();
|
||||||
|
return userHasLegacyRoutines;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert flag is True, "userHasLegacyRoutines must be true when /api/routines/summary returns total > 0"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_legacy_routines_presence_zero_total_clears_flag(page):
|
||||||
|
"""total = 0 → flag clears (covers post-delete case where last legacy routine is gone)."""
|
||||||
|
|
||||||
|
await _route_summary(page, total=0)
|
||||||
|
|
||||||
|
flag = await page.evaluate(
|
||||||
|
"""
|
||||||
|
async () => {
|
||||||
|
userHasLegacyRoutines = true;
|
||||||
|
await refreshLegacyRoutinesPresence();
|
||||||
|
return userHasLegacyRoutines;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert flag is False, "Flag must clear when /api/routines/summary returns total: 0"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_legacy_routines_presence_swallows_fetch_error(page):
|
||||||
|
"""Failed fetch → flag stays at its prior value, promise still resolves (#2982)."""
|
||||||
|
|
||||||
|
async def fail(route):
|
||||||
|
await route.fulfill(status=503, body="server unavailable")
|
||||||
|
|
||||||
|
await page.route("**/api/routines/summary", fail)
|
||||||
|
|
||||||
|
result = await page.evaluate(
|
||||||
|
"""
|
||||||
|
async () => {
|
||||||
|
userHasLegacyRoutines = true; // prior value preserved on failure
|
||||||
|
await refreshLegacyRoutinesPresence();
|
||||||
|
return userHasLegacyRoutines;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Pre-existing value is preserved; the .catch() returns a resolved
|
||||||
|
# promise so callers chained via .then() still run.
|
||||||
|
assert result is True, "Failed summary fetch must not clobber prior flag value"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_delete_refresh_hides_tab_when_last_legacy_routine_removed(page):
|
||||||
|
"""After deleting the last legacy routine the tab falls back to hidden (#2982)."""
|
||||||
|
|
||||||
|
await _route_summary(page, total=0)
|
||||||
|
|
||||||
|
hidden = await page.evaluate(
|
||||||
|
"""
|
||||||
|
async () => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = true;
|
||||||
|
// Simulate the post-delete refresh path that routines.js
|
||||||
|
// deleteRoutine wires up: refresh, then re-apply.
|
||||||
|
await refreshLegacyRoutinesPresence();
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
|
return tab && tab.style.display === 'none';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert hidden, "Routines tab must hide once the last legacy routine is deleted"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_first_status_poll_does_not_refetch_summary_while_in_flight(page):
|
||||||
|
"""`engineModeApplied` flips synchronously so a second poll cannot fan out (#2982)."""
|
||||||
|
|
||||||
|
# Count calls to /api/routines/summary; resolve slowly so a second
|
||||||
|
# call would race the first if the guard were missing.
|
||||||
|
call_counter = {"n": 0}
|
||||||
|
|
||||||
|
async def slow_handler(route):
|
||||||
|
call_counter["n"] += 1
|
||||||
|
await page.wait_for_timeout(50)
|
||||||
|
await route.fulfill(
|
||||||
|
status=200,
|
||||||
|
content_type="application/json",
|
||||||
|
body=json.dumps({
|
||||||
|
"total": 1,
|
||||||
|
"enabled": 1,
|
||||||
|
"disabled": 0,
|
||||||
|
"unverified": 0,
|
||||||
|
"failing": 0,
|
||||||
|
"runs_today": 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.route("**/api/routines/summary", slow_handler)
|
||||||
|
|
||||||
|
final_state = await page.evaluate(
|
||||||
|
"""
|
||||||
|
async () => {
|
||||||
|
// Reset so the call site enters its first-time branch twice
|
||||||
|
// back-to-back. Without the synchronous engineModeApplied=true
|
||||||
|
// assignment, both calls would kick off a /api/routines/summary
|
||||||
|
// fetch (the actual race window described in PR review note #3).
|
||||||
|
engineModeApplied = false;
|
||||||
|
userHasLegacyRoutines = false;
|
||||||
|
const fakeStatus = { engine_v2_enabled: true, restart_enabled: false };
|
||||||
|
|
||||||
|
// Inline mimic of the first-time branch in fetchGatewayStatus.
|
||||||
|
// We can't await fetchGatewayStatus directly because it kicks
|
||||||
|
// off the refresh in a fire-and-forget chain — so drive the
|
||||||
|
// exact same code path here and capture the in-flight side
|
||||||
|
// effect.
|
||||||
|
const fire = () => {
|
||||||
|
if (!engineModeApplied) {
|
||||||
|
engineModeApplied = true;
|
||||||
|
engineV2Enabled = !!fakeStatus.engine_v2_enabled;
|
||||||
|
return refreshLegacyRoutinesPresence().then(function() {
|
||||||
|
applyEngineModeToTabs();
|
||||||
|
applyEngineModeUi();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyEngineModeUi();
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = fire();
|
||||||
|
const b = fire(); // second poll while a is still pending
|
||||||
|
await Promise.all([a, b]);
|
||||||
|
const tab = document.querySelector('.tab-bar [data-tab-role="routines"]');
|
||||||
|
return {
|
||||||
|
applied: engineModeApplied,
|
||||||
|
visible: tab && tab.style.display !== 'none',
|
||||||
|
flag: userHasLegacyRoutines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert call_counter["n"] == 1, (
|
||||||
|
f"second status poll must not race in a duplicate /api/routines/summary fetch "
|
||||||
|
f"(saw {call_counter['n']} calls)"
|
||||||
|
)
|
||||||
|
assert final_state["applied"] is True
|
||||||
|
assert final_state["flag"] is True
|
||||||
|
assert final_state["visible"] is True, "Tab must end up visible once the single fetch resolves"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restore_from_hash_routines_route_reaches_routines_tab_with_legacy_data(page):
|
||||||
|
"""Hash restoration into `routines` lands on the routines tab when legacy data exists."""
|
||||||
|
|
||||||
|
landed_on_routines = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
engineV2Enabled = true;
|
||||||
|
userHasLegacyRoutines = true;
|
||||||
|
// restoreFromHash() consults shouldHideRoutinesTab() in its
|
||||||
|
// 'routines' branch; verify by simulating the decision.
|
||||||
|
return !shouldHideRoutinesTab();
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert landed_on_routines, (
|
||||||
|
"restoreFromHash routines branch must dispatch to Routines, not Missions, "
|
||||||
|
"when legacy routines exist"
|
||||||
|
)
|
||||||
@@ -314,6 +314,20 @@ async fn test_gateway_status_endpoint() {
|
|||||||
let body: serde_json::Value = resp.json().await.unwrap();
|
let body: serde_json::Value = resp.json().await.unwrap();
|
||||||
assert_eq!(body["ws_connections"], 1);
|
assert_eq!(body["ws_connections"], 1);
|
||||||
assert!(body["total_connections"].as_u64().unwrap() >= 1);
|
assert!(body["total_connections"].as_u64().unwrap() >= 1);
|
||||||
|
|
||||||
|
// Regression for #2982: the response carries the engine flag under a
|
||||||
|
// single canonical name. The old duplicate `engine_v2` field led the
|
||||||
|
// gateway JS to read the value from one name while writing the other,
|
||||||
|
// and dropping it locks in the wire-contract rule from
|
||||||
|
// .claude/rules/types.md.
|
||||||
|
assert!(
|
||||||
|
body.get("engine_v2_enabled").is_some(),
|
||||||
|
"expected engine_v2_enabled field on gateway status response"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("engine_v2").is_none(),
|
||||||
|
"duplicate engine_v2 field must not be re-introduced (#2982)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user