mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat(ui): implement R38-lite shared request lifecycle with staged loading, cancel-safe retries, and E2E coverage
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user