0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-04 22:57:34 -04:00

Merge 955aa06eb3d8575072eee6d8f5f80f298e091bb4 into 70685a948979469a8086b2e8d1784a8f27c40f33

This commit is contained in:
Brice Ruth 2025-07-04 18:52:10 -05:00 committed by GitHub
commit 5d773df1c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 430 additions and 16 deletions

View File

@ -0,0 +1,349 @@
/**
* Unit test for RepoActionView's auto-scroll *logic* (the shouldAutoScroll method).
* This test focuses on the predicate that determines if a scroll *should* occur,
* rather than verifying the full end-to-end auto-scroll behavior (DOM interaction or actual scrolling).
*
* This test should FAIL with the original buggy code and PASS with our fix,
* specifically for the 'slightly below viewport' scenario.
*/
import {createApp} from 'vue';
import RepoActionView from './RepoActionView.vue';
// Mock dependencies to isolate the shouldAutoScroll logic
vi.mock('../svg.ts', () => ({
SvgIcon: {template: '<span></span>'},
}));
vi.mock('./ActionRunStatus.vue', () => ({
default: {template: '<span></span>'},
}));
vi.mock('../utils/dom.ts', () => ({
createElementFromAttrs: vi.fn(),
toggleElem: vi.fn(),
}));
vi.mock('../utils/time.ts', () => ({
formatDatetime: vi.fn(() => '2023-01-01'),
}));
vi.mock('../render/ansi.ts', () => ({
renderAnsi: vi.fn((text) => text),
}));
vi.mock('../modules/fetch.ts', () => ({
POST: vi.fn(() => Promise.resolve()),
DELETE: vi.fn(() => Promise.resolve()),
}));
vi.mock('../utils.ts', () => ({
toggleFullScreen: vi.fn(),
}));
describe('RepoActionView auto-scroll logic (shouldAutoScroll method)', () => {
beforeEach(() => {
// Mock window properties for controlled environment
Object.defineProperty(window, 'innerHeight', {
writable: true,
configurable: true,
value: 600,
});
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(() => null),
setItem: vi.fn(),
},
writable: true,
});
// Mock clearInterval and setInterval to prevent actual timer execution
globalThis.clearInterval = vi.fn();
globalThis.setInterval = vi.fn(() => 1 as any);
});
test('should auto-scroll when log element is slightly below viewport (following logs)', () => {
// This test verifies the core behavioral change in the `shouldAutoScroll` method:
// Original code: STRICT check (element must be entirely in viewport)
// Fixed code: LENIENT check (element can be slightly below if user is following logs)
// Mock the last child element's getBoundingClientRect to simulate its position.
// NOTE: This test *mocks* the DOM interaction (getLastLogLineElement and getBoundingClientRect)
// and does not verify the component's ability to correctly find the element or
// that the real DOM element would produce these exact coordinates.
const mockLastChildElement = {
getBoundingClientRect: () => ({
top: 590, // Starts at bottom of 600px viewport
bottom: 610, // Extends 10px below viewport
left: 0,
right: 800,
width: 800,
height: 20,
}),
};
// Create container and mount component for context and state setup
const container = document.createElement('div');
document.body.append(container);
const app = createApp(RepoActionView, {
runIndex: '1',
jobIndex: '0',
actionsURL: '/test',
locale: {
status: {
unknown: 'Unknown', waiting: 'Waiting', running: 'Running',
success: 'Success', failure: 'Failure', cancelled: 'Cancelled',
skipped: 'Skipped', blocked: 'Blocked',
},
approvals_text: 'Approvals', commit: 'Commit', pushedBy: 'Pushed by',
},
});
const vm = app.mount(container) as any;
// Set up component state to enable auto-scroll conditions
vm.optionAlwaysAutoScroll = true;
vm.$data.currentJobStepsStates = [{expanded: true, cursor: null}];
// Mock a running step (required for auto-scroll)
vm.$data.currentJob = {
steps: [{status: 'running'}],
};
// Mock internal methods that interact with the DOM to control inputs to shouldAutoScroll.
// This allows us to precisely test the `shouldAutoScroll` method's logic.
const mockContainer = {
getBoundingClientRect: () => ({
top: 100, // Container is visible in viewport
bottom: 500, // Container extends into viewport
left: 0,
right: 800,
width: 800,
height: 400, // Large container, clearly visible
}),
};
vm.getJobStepLogsContainer = vi.fn(() => mockContainer); // Return mock container
vm.getLastLogLineElement = vi.fn(() => mockLastChildElement); // Return the test element
// Test the actual component's shouldAutoScroll method
const shouldScroll = vm.shouldAutoScroll(0);
// CRITICAL BEHAVIORAL TEST (for the predicate logic):
// When element is slightly below viewport (simulating user following logs), should auto-scroll?
// Original buggy code: FALSE (too strict - requires element entirely in viewport)
// Fixed code: TRUE (lenient - allows slight overflow for better UX)
expect(shouldScroll).toBe(true);
// Cleanup
app.unmount();
container.remove();
});
test('should NOT auto-scroll when element is far below viewport (user scrolled up)', () => {
// Both original and fixed code should agree on this case.
// This scenario simulates a user having scrolled up significantly.
// Mock the last child element's getBoundingClientRect to simulate its position.
// As with other tests, this directly feeds values to `shouldAutoScroll` without
// verifying actual DOM rendering or element finding.
const mockLastChildElement = {
getBoundingClientRect: () => ({
top: 800, // Way below 600px viewport
bottom: 820,
left: 0,
right: 800,
width: 800,
height: 20,
}),
};
const container = document.createElement('div');
document.body.append(container);
const app = createApp(RepoActionView, {
runIndex: '1',
jobIndex: '0',
actionsURL: '/test',
locale: {
status: {
unknown: 'Unknown', waiting: 'Waiting', running: 'Running',
success: 'Success', failure: 'Failure', cancelled: 'Cancelled',
skipped: 'Skipped', blocked: 'Blocked',
},
approvals_text: 'Approvals', commit: 'Commit', pushedBy: 'Pushed by',
},
});
const vm = app.mount(container) as any;
vm.optionAlwaysAutoScroll = true;
vm.$data.currentJobStepsStates = [{expanded: true, cursor: null}];
// Mock a running step (so the failure is due to scroll position, not step status)
vm.$data.currentJob = {
steps: [{status: 'running'}],
};
// Mock a container that's far above viewport (user scrolled past it)
const mockContainer = {
getBoundingClientRect: () => ({
top: -300, // Container is above viewport
bottom: -100, // Container ends above viewport
left: 0,
right: 800,
width: 800,
height: 200,
}),
};
vm.getJobStepLogsContainer = vi.fn(() => mockContainer);
vm.getLastLogLineElement = vi.fn(() => mockLastChildElement);
const shouldScroll = vm.shouldAutoScroll(0);
// The `shouldAutoScroll` logic should return false here.
expect(shouldScroll).toBe(false);
app.unmount();
container.remove();
});
test('should NOT auto-scroll when step is not expanded', () => {
// This test verifies that auto-scroll is prevented when the job step is not expanded,
// regardless of the log element's position.
const container = document.createElement('div');
document.body.append(container);
const app = createApp(RepoActionView, {
runIndex: '1',
jobIndex: '0',
actionsURL: '/test',
locale: {
status: {
unknown: 'Unknown', waiting: 'Waiting', running: 'Running',
success: 'Success', failure: 'Failure', cancelled: 'Cancelled',
skipped: 'Skipped', blocked: 'Blocked',
},
approvals_text: 'Approvals', commit: 'Commit', pushedBy: 'Pushed by',
},
});
const vm = app.mount(container) as any;
vm.optionAlwaysAutoScroll = true;
vm.$data.currentJobStepsStates = [{expanded: false, cursor: null}]; // Not expanded
const shouldScroll = vm.shouldAutoScroll(0);
// The `shouldAutoScroll` logic should return false.
expect(shouldScroll).toBe(false);
app.unmount();
container.remove();
});
test('should NOT auto-scroll when step is finished (not running)', () => {
// Auto-scroll should only happen for currently executing steps, not finished ones
// Mock log element that would normally trigger auto-scroll
const mockLastLogElement = {
getBoundingClientRect: () => ({
top: 590, // Near bottom of viewport (would normally auto-scroll)
bottom: 610,
left: 0,
right: 800,
width: 800,
height: 20,
}),
};
const container = document.createElement('div');
document.body.append(container);
const app = createApp(RepoActionView, {
runIndex: '1',
jobIndex: '0',
actionsURL: '/test',
locale: {
status: {
unknown: 'Unknown', waiting: 'Waiting', running: 'Running',
success: 'Success', failure: 'Failure', cancelled: 'Cancelled',
skipped: 'Skipped', blocked: 'Blocked',
},
approvals_text: 'Approvals', commit: 'Commit', pushedBy: 'Pushed by',
},
});
const vm = app.mount(container) as any;
vm.optionAlwaysAutoScroll = true;
vm.$data.currentJobStepsStates = [{expanded: true, cursor: null}];
// Mock a finished step (success status)
vm.$data.currentJob = {
steps: [{status: 'success'}],
};
vm.getJobStepLogsContainer = vi.fn(() => ({}));
vm.getLastLogLineElement = vi.fn(() => mockLastLogElement);
const shouldScroll = vm.shouldAutoScroll(0);
// Should NOT auto-scroll for finished steps, even if logs are following-friendly
expect(shouldScroll).toBe(false);
app.unmount();
container.remove();
});
test('should auto-scroll when step is running and user following logs', () => {
// This ensures we still auto-scroll when the step is actively running and user is following
// Mock log element that suggests user is following
const mockLastLogElement = {
getBoundingClientRect: () => ({
top: 590,
bottom: 610, // Slightly below viewport (normal following behavior)
left: 0,
right: 800,
width: 800,
height: 20,
}),
};
const container = document.createElement('div');
document.body.append(container);
const app = createApp(RepoActionView, {
runIndex: '1',
jobIndex: '0',
actionsURL: '/test',
locale: {
status: {
unknown: 'Unknown', waiting: 'Waiting', running: 'Running',
success: 'Success', failure: 'Failure', cancelled: 'Cancelled',
skipped: 'Skipped', blocked: 'Blocked',
},
approvals_text: 'Approvals', commit: 'Commit', pushedBy: 'Pushed by',
},
});
const vm = app.mount(container) as any;
vm.optionAlwaysAutoScroll = true;
vm.$data.currentJobStepsStates = [{expanded: true, cursor: null}];
// Mock a running step
vm.$data.currentJob = {
steps: [{status: 'running'}],
};
vm.getJobStepLogsContainer = vi.fn(() => ({}));
vm.getLastLogLineElement = vi.fn(() => mockLastLogElement);
const shouldScroll = vm.shouldAutoScroll(0);
// SHOULD auto-scroll when step is running and user is following logs
expect(shouldScroll).toBe(true);
app.unmount();
container.remove();
});
});

View File

@ -54,9 +54,16 @@ function parseLineCommand(line: LogLine): LogLineCommand | null {
return null;
}
function isLogElementInViewport(el: Element): boolean {
function isUserFollowingLogs(el: Element): boolean {
const rect = el.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= window.innerHeight; // only check height but not width
const windowHeight = window.innerHeight;
// Check if the user is "following" the logs by seeing if the element is near the bottom
// We're more lenient than the original strict check, but still reasonable:
// - Element's top should be at or before the viewport bottom (user hasn't scrolled far up)
// - Element's bottom should be within reasonable range of viewport (not too far below)
const threshold = windowHeight * 0.1; // Allow element to extend 10% of viewport height below
return rect.top <= windowHeight && rect.bottom <= windowHeight + threshold;
}
type LocaleStorageOptions = {
@ -114,6 +121,7 @@ export default defineComponent({
},
optionAlwaysAutoScroll: autoScroll ?? false,
optionAlwaysExpandRunning: expandRunning ?? false,
lastAutoScrollTime: 0,
// provided by backend
run: {
@ -218,6 +226,38 @@ export default defineComponent({
// @ts-expect-error - _stepLogsActiveContainer is a custom property
return el._stepLogsActiveContainer ?? el;
},
// get the actual last log line element in a step, accounting for nested groups
getLastLogLineElement(stepIndex: number): Element | null {
const container = this.getJobStepLogsContainer(stepIndex);
if (!container) return null;
// Find the actual last log line, which might be nested in groups
// We need to check if groups are expanded (open) to find the truly visible last line
const findLastLogLine = (element: Element): Element | null => {
let lastChild = element.lastElementChild;
while (lastChild) {
if (lastChild.classList.contains('job-log-line')) {
return lastChild;
}
if (lastChild.classList.contains('job-log-group')) {
// Only look inside groups that are open (expanded)
const detailsElement = lastChild as HTMLDetailsElement;
if (detailsElement.open) {
const nestedLast = findLastLogLine(lastChild);
if (nestedLast) return nestedLast;
}
// If group is closed, the summary line is the visible "last" element
const summary = lastChild.querySelector('.job-log-group-summary .job-log-line');
if (summary) return summary;
}
lastChild = lastChild.previousElementSibling;
}
return null;
};
return findLastLogLine(container);
},
// begin a log group
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex];
@ -290,10 +330,21 @@ export default defineComponent({
shouldAutoScroll(stepIndex: number): boolean {
if (!this.optionAlwaysAutoScroll) return false;
const el = this.getJobStepLogsContainer(stepIndex);
// if the logs container is empty, then auto-scroll if the step is expanded
if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded;
return isLogElementInViewport(el.lastChild as Element);
if (!this.currentJobStepsStates[stepIndex]?.expanded) return false;
// Only auto-scroll for currently executing (active) groups, not finished ones
const step = this.currentJob?.steps?.[stepIndex];
if (!step || step.status !== 'running') return false;
// Get the step logs container to check scroll position
const container = this.getJobStepLogsContainer(stepIndex);
if (!container) return true; // If no container yet, auto-scroll when it appears
const lastLogLine = this.getLastLogLineElement(stepIndex);
if (!lastLogLine) return true; // If no logs yet, auto-scroll when logs appear
// Check if user is following the logs within this step
return isUserFollowingLogs(lastLogLine);
},
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
@ -360,11 +411,12 @@ export default defineComponent({
}
}
// find the step indexes that need to auto-scroll
const autoScrollStepIndexes = new Map<number, boolean>();
// Check which steps should auto-scroll BEFORE appending new logs
const shouldAutoScrollSteps = new Set<number>();
for (const logs of job.logs.stepsLog ?? []) {
if (autoScrollStepIndexes.has(logs.step)) continue;
autoScrollStepIndexes.set(logs.step, this.shouldAutoScroll(logs.step));
if (this.shouldAutoScroll(logs.step)) {
shouldAutoScrollSteps.add(logs.step);
}
}
// append logs to the UI
@ -374,13 +426,26 @@ export default defineComponent({
this.appendLogs(logs.step, logs.started, logs.lines);
}
// auto-scroll to the last log line of the last step
let autoScrollJobStepElement: HTMLElement;
for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) {
if (!autoScrollStepIndexes.get(stepIndex)) continue;
autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex);
// Auto-scroll to the last log line for steps that should auto-scroll
// Do this AFTER appending logs so the DOM is up to date
// Use requestAnimationFrame to ensure DOM is fully updated
if (shouldAutoScrollSteps.size > 0) {
const now = Date.now();
// Throttle auto-scroll to prevent rapid corrections (max once per 100ms)
if (now - this.lastAutoScrollTime > 100) {
this.lastAutoScrollTime = now;
requestAnimationFrame(() => {
for (const stepIndex of shouldAutoScrollSteps) {
const lastLogLine = this.getLastLogLineElement(stepIndex);
if (lastLogLine) {
// Use instant scrolling to avoid conflicts with user scrolling
// and scroll to the bottom of the element to ensure it's fully visible
lastLogLine.scrollIntoView({behavior: 'instant', block: 'end'});
}
}
});
}
}
autoScrollJobStepElement?.lastElementChild.scrollIntoView({behavior: 'smooth', block: 'nearest'});
// clear the interval timer if the job is done
if (this.run.done && this.intervalID) {