feat(ui): implement R38-lite shared request lifecycle with staged loading, cancel-safe retries, and E2E coverage

This commit is contained in:
rookiestar28
2026-02-24 02:37:31 +08:00
parent b3a0f7d2ad
commit ceb282085e
4 changed files with 257 additions and 114 deletions
+116
View File
@@ -0,0 +1,116 @@
import { test, expect } from '@playwright/test';
import { mockComfyUiCore, waitForOpenClawReady, clickTab } from '../utils/helpers.js';
test.describe('R38 Lite UX lifecycle', () => {
test.beforeEach(async ({ page }) => {
await mockComfyUiCore(page);
await page.route('**/openclaw/config', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, config: {}, apply: {} }) });
});
await page.route('**/openclaw/logs/tail*', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, content: [] }) });
});
await page.route('**/openclaw/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, pack: { version: 'test' } }) });
});
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
});
test('Planner shows staged loading + elapsed timer and then succeeds', async ({ page }) => {
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
await page.route('**/openclaw/assist/planner', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1700));
try {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
positive: 'A foggy mountain valley',
negative: 'lowres, blurry',
params: { width: 1024, height: 1024 },
}),
});
} catch {
// Request may already be aborted by navigation/cancel in edge races.
}
});
await clickTab(page, 'Planner');
await page.locator('#planner-run-btn').click();
await expect(page.locator('#planner-loading')).toBeVisible();
await expect(page.locator('#planner-stage')).toContainText('Waiting for provider response...', { timeout: 2000 });
await expect(page.locator('#planner-elapsed')).not.toHaveText('Elapsed: 0s', { timeout: 2500 });
await expect(page.locator('#planner-out-pos')).toHaveValue('A foggy mountain valley');
await expect(page.locator('#planner-out-neg')).toHaveValue('lowres, blurry');
await expect(page.locator('#planner-loading')).toBeHidden();
await expect(page.locator('#planner-run-btn')).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('Refiner cancel keeps UI stable and retry succeeds', async ({ page }) => {
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
let callCount = 0;
await page.route('**/openclaw/assist/refiner', async (route) => {
callCount += 1;
if (callCount === 1) {
await new Promise((resolve) => setTimeout(resolve, 2500));
try {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
refined_positive: 'stale response should be ignored',
refined_negative: 'stale',
rationale: 'stale',
}),
});
} catch {
// Cancel path may abort before fulfill.
}
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
refined_positive: 'clean cinematic portrait lighting',
refined_negative: 'overexposed, noisy',
rationale: 'Adjusted lighting and constrained noise artifacts.',
}),
});
});
await clickTab(page, 'Refiner');
await page.locator('#refiner-orig-pos').fill('portrait, natural light');
await page.locator('#refiner-issue').fill('too noisy and inconsistent lighting');
await page.locator('#refiner-run-btn').click();
await expect(page.locator('#refiner-loading')).toBeVisible();
await expect(page.locator('#refiner-stage')).toContainText('Waiting for provider response...', { timeout: 2000 });
await page.locator('#refiner-cancel-btn').click();
await expect(page.locator('#refiner-loading')).toBeHidden();
await expect(page.locator('#refiner-run-btn')).toBeVisible();
await expect(page.locator('.openclaw-toast')).toContainText('Request cancelled by user');
await page.locator('#refiner-run-btn').click();
await expect(page.locator('#refiner-new-pos')).toHaveValue('clean cinematic portrait lighting');
await expect(page.locator('#refiner-new-neg')).toHaveValue('overexposed, noisy');
await expect(page.locator('#refiner-rationale')).toContainText('Adjusted lighting');
expect(pageErrors).toEqual([]);
});
});
+79
View File
@@ -105,3 +105,82 @@ export async function copyToClipboard(text, btnElement) {
alert("Failed to copy to clipboard");
}
}
/**
* R38-Lite: Create a shared request lifecycle controller for staged loading + elapsed timer + cancel.
*
* @param {HTMLElement} container
* @param {object} selectors
* @param {string} selectors.loading
* @param {string} selectors.runButton
* @param {string} selectors.stage
* @param {string} selectors.elapsed
*/
export function createRequestLifecycleController(container, selectors) {
const loadingEl = container.querySelector(selectors.loading);
const runBtnEl = container.querySelector(selectors.runButton);
const stageEl = container.querySelector(selectors.stage);
const elapsedEl = container.querySelector(selectors.elapsed);
let abortController = null;
let timerInterval = null;
let startTime = 0;
const showLoading = (show) => {
if (loadingEl) loadingEl.style.display = show ? "block" : "none";
if (runBtnEl) runBtnEl.style.display = show ? "none" : "block";
};
const stopTimer = () => {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
const setStage = (text) => {
if (stageEl) stageEl.textContent = text;
};
const startTimer = () => {
startTime = Date.now();
if (elapsedEl) elapsedEl.textContent = "Elapsed: 0s";
stopTimer();
timerInterval = setInterval(() => {
if (!elapsedEl) return;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
elapsedEl.textContent = `Elapsed: ${elapsed}s`;
}, 500);
};
const begin = (initialStage = "Preparing request...") => {
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
setStage(initialStage);
showLoading(true);
startTimer();
return abortController.signal;
};
const end = () => {
stopTimer();
showLoading(false);
abortController = null;
};
const cancel = () => {
if (!abortController) return false;
abortController.abort();
end();
return true;
};
return {
begin,
end,
cancel,
setStage,
};
}
+31 -57
View File
@@ -1,5 +1,5 @@
import { openclawApi } from "../openclaw_api.js";
import { showError, clearError } from "../openclaw_utils.js";
import { showError, clearError, showToast, createRequestLifecycleController } from "../openclaw_utils.js";
export const PlannerTab = {
id: "planner",
@@ -76,34 +76,13 @@ export const PlannerTab = {
</style>
`;
// R38-Lite: Abort controller for cancellation
let abortController = null;
let timerInterval = null;
let startTime = 0;
const updateStage = (stage) => {
container.querySelector("#planner-stage").textContent = stage;
};
const startTimer = () => {
startTime = Date.now();
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
container.querySelector("#planner-elapsed").textContent = `Elapsed: ${elapsed}s`;
}, 500);
};
const stopTimer = () => {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
const showLoading = (show) => {
container.querySelector("#planner-loading").style.display = show ? "block" : "none";
container.querySelector("#planner-run-btn").style.display = show ? "none" : "block";
};
const lifecycle = createRequestLifecycleController(container, {
loading: "#planner-loading",
runButton: "#planner-run-btn",
stage: "#planner-stage",
elapsed: "#planner-elapsed",
});
let activeRequestId = 0;
container.querySelector("#planner-run-btn").onclick = async () => {
const profile = container.querySelector("#planner-profile").value;
@@ -115,21 +94,13 @@ export const PlannerTab = {
clearError(container);
resDiv.style.display = "none";
// R38-Lite: Create abort controller
abortController = new AbortController();
showLoading(true);
updateStage("Preparing request...");
startTimer();
const requestId = ++activeRequestId;
const signal = lifecycle.begin("Preparing request...");
try {
// Stage 1: Preparing
await new Promise(resolve => setTimeout(resolve, 100)); // Brief delay to show stage
updateStage("Sending request to backend...");
// Stage 2: Sending
await new Promise(resolve => setTimeout(resolve, 50));
updateStage("Waiting for provider response...");
lifecycle.setStage("Sending request to backend...");
await new Promise((resolve) => requestAnimationFrame(resolve));
lifecycle.setStage("Waiting for provider response...");
const res = await openclawApi.runPlanner(
{
@@ -137,42 +108,45 @@ export const PlannerTab = {
requirements: reqs,
style_directives: style
},
abortController.signal // Pass signal (note: runPlanner needs to support this)
signal
);
stopTimer();
if (requestId !== activeRequestId) {
return;
}
if (res.ok) {
updateStage("Parsing and validating output...");
lifecycle.setStage("Parsing and validating output...");
await new Promise((resolve) => requestAnimationFrame(resolve));
resDiv.style.display = "flex"; // Re-enable flex layout
container.querySelector("#planner-out-pos").value = res.data.positive || "";
container.querySelector("#planner-out-neg").value = res.data.negative || "";
container.querySelector("#planner-out-params").value = JSON.stringify(res.data.params || {}, null, 2);
showLoading(false);
} else if (res.error === "timeout") {
showLoading(false);
showError(container, "Request timed out");
} else if (res.error === "cancelled") {
// User cancelled
showLoading(false);
showError(container, "Request cancelled by user");
showToast("Request cancelled by user", "info");
} else {
showLoading(false);
showError(container, res.error || "Planning failed");
}
} catch (err) {
stopTimer();
showLoading(false);
if (requestId !== activeRequestId) {
return;
}
showError(container, err.message || "Unexpected error");
} finally {
if (requestId === activeRequestId) {
lifecycle.end();
}
}
};
// R38-Lite: Cancel button handler
container.querySelector("#planner-cancel-btn").onclick = () => {
if (abortController) {
abortController.abort();
stopTimer();
showLoading(false);
if (lifecycle.cancel()) {
// Invalidate pending promise handlers so stale responses cannot mutate UI.
activeRequestId += 1;
showToast("Request cancelled by user", "info");
}
};
}
+31 -57
View File
@@ -1,5 +1,5 @@
import { openclawApi } from "../openclaw_api.js";
import { showError, clearError } from "../openclaw_utils.js";
import { showError, clearError, showToast, createRequestLifecycleController } from "../openclaw_utils.js";
export const RefinerTab = {
id: "refiner",
@@ -101,55 +101,26 @@ export const RefinerTab = {
}
};
// R38-Lite: Abort controller for cancellation
let abortController = null;
let timerInterval = null;
let startTime = 0;
const updateStage = (stage) => {
container.querySelector("#refiner-stage").textContent = stage;
};
const startTimer = () => {
startTime = Date.now();
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
container.querySelector("#refiner-elapsed").textContent = `Elapsed: ${elapsed}s`;
}, 500);
};
const stopTimer = () => {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
const showLoading = (show) => {
container.querySelector("#refiner-loading").style.display = show ? "block" : "none";
container.querySelector("#refiner-run-btn").style.display = show ? "none" : "block";
};
const lifecycle = createRequestLifecycleController(container, {
loading: "#refiner-loading",
runButton: "#refiner-run-btn",
stage: "#refiner-stage",
elapsed: "#refiner-elapsed",
});
let activeRequestId = 0;
container.querySelector("#refiner-run-btn").onclick = async () => {
clearError(container);
const resDiv = container.querySelector("#refiner-results");
resDiv.style.display = "none";
// R38-Lite: Create abort controller
abortController = new AbortController();
showLoading(true);
updateStage("Preparing request...");
startTimer();
const requestId = ++activeRequestId;
const signal = lifecycle.begin("Preparing request...");
try {
// Stage 1: Preparing
await new Promise(resolve => setTimeout(resolve, 100));
updateStage("Sending request to backend...");
// Stage 2: Sending
await new Promise(resolve => setTimeout(resolve, 50));
updateStage("Waiting for provider response...");
lifecycle.setStage("Sending request to backend...");
await new Promise((resolve) => requestAnimationFrame(resolve));
lifecycle.setStage("Waiting for provider response...");
const res = await openclawApi.runRefiner(
{
@@ -158,43 +129,46 @@ export const RefinerTab = {
orig_negative: container.querySelector("#refiner-orig-neg").value,
issue: container.querySelector("#refiner-issue").value
},
abortController.signal
signal
);
stopTimer();
if (requestId !== activeRequestId) {
return;
}
if (res.ok) {
updateStage("Parsing and validating output...");
lifecycle.setStage("Parsing and validating output...");
await new Promise((resolve) => requestAnimationFrame(resolve));
container.querySelector("#refiner-new-pos").value = res.data.refined_positive || "";
container.querySelector("#refiner-new-neg").value = res.data.refined_negative || "";
container.querySelector("#refiner-rationale").textContent = res.data.rationale || "";
resDiv.style.display = "flex";
showLoading(false);
} else if (res.error === "timeout") {
showLoading(false);
showError(container, "Request timed out");
} else if (res.error === "cancelled") {
// User cancelled
showLoading(false);
showError(container, "Request cancelled by user");
showToast("Request cancelled by user", "info");
} else {
showLoading(false);
showError(container, res.error || "Refinement failed");
}
} catch (e) {
stopTimer();
showLoading(false);
if (requestId !== activeRequestId) {
return;
}
showError(container, `Refine Failed: ${e.message}`);
} finally {
if (requestId === activeRequestId) {
lifecycle.end();
}
}
};
// R38-Lite: Cancel button handler
container.querySelector("#refiner-cancel-btn").onclick = () => {
if (abortController) {
abortController.abort();
stopTimer();
showLoading(false);
if (lifecycle.cancel()) {
// Invalidate pending promise handlers so stale responses cannot mutate UI.
activeRequestId += 1;
showToast("Request cancelled by user", "info");
}
};
}