fix(gateway): preserve log scroll position

This commit is contained in:
2026-08-15 05:30:52 +00:00
parent 47e629eb4b
commit 8aa9736512
2 changed files with 218 additions and 6 deletions
@@ -18,6 +18,15 @@ type Operation = {
updatedAt: string;
};
type LogEntry = {
cursor: string;
operationId: string;
level: 'INFO' | 'OUTPUT' | 'ERROR';
phase: string;
message: string;
createdAt: string;
};
type FixtureState = {
operations: Operation[];
gatewayOperations: Array<{
@@ -39,8 +48,12 @@ type FixtureState = {
profileLogPollCount?: number;
profileLogProgress?: boolean;
profileLogsEmpty?: boolean;
profileLogBatches?: LogEntry[][];
profileLogPollGate?: (pollCount: number) => Promise<void>;
gatewayLogPollCount?: number;
gatewayLogsEmpty?: boolean;
gatewayLogBatches?: LogEntry[][];
gatewayLogPollGate?: (pollCount: number) => Promise<void>;
gatewayStateFailuresAfterRequest?: number;
gatewayStateFailuresRemaining?: number;
gatewayStateFailureCount?: number;
@@ -160,6 +173,12 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (names.includes('admin.operations.logs') && state.profileLogPollGate) {
await state.profileLogPollGate((state.profileLogPollCount ?? 0) + 1);
}
if (names.includes('admin.releases.logs') && state.gatewayLogPollGate) {
await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1);
}
const results = names.map((name) => {
if (route.request().method() === 'POST') {
state.requestBodies.push({ operation: name, body });
@@ -197,6 +216,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
const operation = state.operations[0];
if (!operation) throw new Error('Profile operation fixture is missing');
state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1;
if (state.profileLogBatches) {
const entries = state.profileLogBatches[state.profileLogPollCount - 1] ?? [];
const completed = state.profileLogPollCount >= state.profileLogBatches.length;
const nextOperation = {
...operation,
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
};
state.operations[0] = nextOperation;
return response({
operation: nextOperation,
entries,
nextCursor: entries.at(-1)?.cursor,
});
}
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) {
return response({ operation, entries: [] });
}
@@ -258,6 +291,20 @@ const installFixture = async (page: Page, state: FixtureState) => {
const releaseOperation = state.gatewayOperations[0];
if (!releaseOperation) throw new Error('Release operation fixture is missing');
state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1;
if (state.gatewayLogBatches) {
const entries = state.gatewayLogBatches[state.gatewayLogPollCount - 1] ?? [];
const completed = state.gatewayLogPollCount >= state.gatewayLogBatches.length;
const nextOperation = {
...releaseOperation,
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
};
state.gatewayOperations[0] = nextOperation;
return response({
operation: nextOperation,
entries,
nextCursor: entries.at(-1)?.cursor,
});
}
if (state.gatewayLogsEmpty) {
return response({ operation: releaseOperation, entries: [] });
}
@@ -408,6 +455,27 @@ const installFixture = async (page: Page, state: FixtureState) => {
});
};
const makeLogEntries = (operationId: string, prefix: string, startCursor: number, count: number): LogEntry[] =>
Array.from({ length: count }, (_, index) => {
const cursor = startCursor + index;
return {
cursor: String(cursor),
operationId,
level: 'OUTPUT',
phase: 'build',
message: `${prefix} ${cursor}`,
createdAt: '2026-08-01T01:00:00.000Z',
};
});
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
};
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
page,
}, testInfo) => {
@@ -634,6 +702,142 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
for (const viewportSize of [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`follows new profile and Gateway logs only while each viewport is near the end on ${viewportSize.name}`, async ({
page,
}, testInfo) => {
await page.setViewportSize(viewportSize);
const profileOperationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const gatewayOperationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const profileSecondPoll = deferred();
const profileThirdPoll = deferred();
const gatewaySecondPoll = deferred();
const gatewayThirdPoll = deferred();
const state: FixtureState = {
operations: [
{
id: profileOperationId,
profileName: 'che:default',
type: 'DEPLOY',
status: 'RUNNING',
sourceMode: 'BRANCH',
sourceRef: 'main',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z',
},
],
gatewayOperations: [
{
id: gatewayOperationId,
type: 'DEPLOY',
status: 'RUNNING',
sourceMode: 'BRANCH',
sourceRef: 'main',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-01T02:00:00.000Z',
updatedAt: '2026-08-01T02:00:00.000Z',
},
],
runtimeRunning: true,
requestBodies: [],
profileLogProgress: true,
profileLogBatches: [
makeLogEntries(profileOperationId, 'profile history', 1, 80),
makeLogEntries(profileOperationId, 'profile while reading', 81, 1),
makeLogEntries(profileOperationId, 'profile near end', 82, 1),
],
profileLogPollGate: async (pollCount) => {
if (pollCount === 2) await profileSecondPoll.promise;
if (pollCount === 3) await profileThirdPoll.promise;
},
gatewayLogBatches: [
makeLogEntries(gatewayOperationId, 'gateway history', 1, 80),
makeLogEntries(gatewayOperationId, 'gateway while reading', 81, 1),
makeLogEntries(gatewayOperationId, 'gateway near end', 82, 1),
],
gatewayLogPollGate: async (pollCount) => {
if (pollCount === 2) await gatewaySecondPoll.promise;
if (pollCount === 3) await gatewayThirdPoll.promise;
},
};
await installFixture(page, state);
const verifyViewport = async (
url: string,
testId: 'profile-operation-log' | 'gateway-release-log',
historyText: string,
readingText: string,
nearEndText: string,
releaseSecondPoll: () => void,
releaseThirdPoll: () => void,
screenshotName: string
) => {
await page.goto(url);
const viewport = page.getByTestId(testId);
await expect(viewport).toContainText(historyText);
await expect
.poll(() =>
viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
)
.toBeLessThanOrEqual(1);
const readingPosition = await viewport.evaluate((element) => {
element.scrollTop = 200;
return element.scrollTop;
});
expect(readingPosition).toBe(200);
releaseSecondPoll();
await expect(viewport).toContainText(readingText);
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(readingPosition);
const nearEndGap = await viewport.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 20;
return element.scrollHeight - element.clientHeight - element.scrollTop;
});
expect(nearEndGap).toBeGreaterThan(0);
expect(nearEndGap).toBeLessThanOrEqual(40);
releaseThirdPoll();
await expect(viewport).toContainText(nearEndText);
await expect
.poll(() =>
viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
)
.toBeLessThanOrEqual(1);
await page.screenshot({
path: testInfo.outputPath(`${viewportSize.name}-${screenshotName}`),
fullPage: true,
});
};
await verifyViewport(
'admin/servers/che%3Adefault/version',
'profile-operation-log',
'profile history 80',
'profile while reading 81',
'profile near end 82',
profileSecondPoll.resolve,
profileThirdPoll.resolve,
'profile-log-scroll-follow.png'
);
await verifyViewport(
'admin/releases',
'gateway-release-log',
'gateway history 80',
'gateway while reading 81',
'gateway near end 82',
gatewaySecondPoll.resolve,
gatewayThirdPoll.resolve,
'gateway-log-scroll-follow.png'
);
});
}
test('loads server metadata defaults into the reset form and submits them', async ({ page }) => {
const state: FixtureState = {
operations: [],
@@ -115,6 +115,12 @@ let releaseLogLoopGeneration = 0;
let profileLogLoopGeneration = 0;
let componentMounted = false;
let gatewayReleaseTransitionActive = false;
const LOG_SCROLL_FOLLOW_THRESHOLD_PX = 40;
const isLogViewportNearEnd = (viewport?: HTMLElement): boolean => {
if (!viewport) return true;
return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= LOG_SCROLL_FOLLOW_THRESHOLD_PX;
};
const form = reactive({
sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT',
@@ -326,10 +332,10 @@ const loadState = async (quiet = false) => {
}
};
const scrollProfileOperationLogToEnd = async () => {
const scrollProfileOperationLogToEnd = async (shouldFollow: boolean) => {
await nextTick();
const viewport = profileOperationLogViewport.value;
if (viewport) viewport.scrollTop = viewport.scrollHeight;
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
};
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
@@ -349,11 +355,12 @@ const pollProfileOperationLogs = async (operationId: string, generation: number)
profileOperationLogConnection.value = 'connected';
const entries = result.entries as GatewayReleaseLog[];
if (entries.length) {
const shouldFollow = isLogViewportNearEnd(profileOperationLogViewport.value);
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
profileOperationLogCursor.value = result.nextCursor;
await scrollProfileOperationLogToEnd();
await scrollProfileOperationLogToEnd(shouldFollow);
}
const operation = result.operation as Operation;
profileOperationLogStatus.value = operation.status;
@@ -381,10 +388,10 @@ const selectProfileOperation = (operationId: string) => {
selectedProfileOperationId.value = operationId;
};
const scrollReleaseLogToEnd = async () => {
const scrollReleaseLogToEnd = async (shouldFollow: boolean) => {
await nextTick();
const viewport = gatewayReleaseLogViewport.value;
if (viewport) viewport.scrollTop = viewport.scrollHeight;
if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight;
};
const pollGatewayReleaseLogs = async (operationId: string, generation: number) => {
@@ -404,11 +411,12 @@ const pollGatewayReleaseLogs = async (operationId: string, generation: number) =
gatewayReleaseLogConnection.value = 'connected';
const entries = result.entries as GatewayReleaseLog[];
if (entries.length) {
const shouldFollow = isLogViewportNearEnd(gatewayReleaseLogViewport.value);
const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor));
gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000);
gatewayReleaseLogCursor.value = result.nextCursor;
await scrollReleaseLogToEnd();
await scrollReleaseLogToEnd(shouldFollow);
}
const operation = result.operation as GatewayReleaseOperation;
gatewayReleaseLogStatus.value = operation.status;