diff --git a/package-lock.json b/package-lock.json index 21e29a2081d78..cf2fa3cf8a791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "@typescript-eslint/parser": "8.35.1", "@vitejs/plugin-vue": "6.0.0", "@vitest/eslint-plugin": "1.3.4", + "@vue/test-utils": "2.4.6", "eslint": "8.57.0", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-array-func": "4.0.0", @@ -1586,6 +1587,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4062,6 +4070,17 @@ "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4264,6 +4283,16 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -5270,6 +5299,24 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/core-js": { "version": "3.32.2", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz", @@ -6322,6 +6369,68 @@ "marked": "^4.1.0" } }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.179", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", @@ -8598,6 +8707,132 @@ "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "license": "MIT" }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-levenshtein-esm": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/js-levenshtein-esm/-/js-levenshtein-esm-2.0.0.tgz", @@ -10253,6 +10488,22 @@ "node": ">=12.4.0" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -11244,6 +11495,13 @@ "dev": true, "license": "Unlicense" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proto-props": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/proto-props/-/proto-props-2.0.0.tgz", @@ -13876,6 +14134,13 @@ "vue": "^3.0.0-0 || ^2.7.0" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", diff --git a/package.json b/package.json index 4b4f22351b252..0055e4b4de6c7 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@typescript-eslint/parser": "8.35.1", "@vitejs/plugin-vue": "6.0.0", "@vitest/eslint-plugin": "1.3.4", + "@vue/test-utils": "2.4.6", "eslint": "8.57.0", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-array-func": "4.0.0", diff --git a/web_src/js/components/RepoActionView.autoExpand.test.ts b/web_src/js/components/RepoActionView.autoExpand.test.ts new file mode 100644 index 0000000000000..b57dcfebc059c --- /dev/null +++ b/web_src/js/components/RepoActionView.autoExpand.test.ts @@ -0,0 +1,192 @@ +import {test, expect, beforeEach, afterEach, vi} from 'vitest'; +import {mount} from '@vue/test-utils'; +import RepoActionView from './RepoActionView.vue'; + +/** + * Focused tests for RepoActionView auto-expand functionality. + * + * This test suite specifically targets the "Always expand running logs" setting. + */ + +// Helper function to create default props +function createDefaultProps() { + return { + runIndex: '1', + jobIndex: '0', + actionsURL: '/test/actions', + locale: { + status: {}, + approve: 'Approve', + cancel: 'Cancel', + rerun_all: 'Rerun all', + scheduled: 'Scheduled', + commit: 'Commit', + pushedBy: 'pushed by', + logsAlwaysAutoScroll: 'Always auto scroll logs', + logsAlwaysExpandRunning: 'Always expand running logs', + showLogSeconds: 'Show seconds', + showTimeStamps: 'Show timestamps', + showFullScreen: 'Show full screen', + downloadLogs: 'Download logs', + artifactsTitle: 'Artifacts', + artifactExpired: 'Expired', + confirmDeleteArtifact: 'Confirm delete artifact: %s', + rerun: 'Rerun', + }, + }; +} + +// Helper function to create mock API response +function createMockApiResponse(steps: any[] = [], runStatus = 'running') { + return { + artifacts: [] as any[], + state: { + run: { + link: '', + title: '', + titleHTML: '', + status: runStatus, + canCancel: false, + canApprove: false, + canRerun: false, + canDeleteArtifact: false, + done: false, + workflowID: '', + workflowLink: '', + isSchedule: false, + jobs: [] as any[], + commit: { + localeCommit: '', + localePushedBy: '', + shortSHA: '', + link: '', + pusher: {displayName: '', link: ''}, + branch: {name: '', link: '', isDeleted: false}, + }, + }, + currentJob: { + title: 'Test Job', + detail: 'Test job detail', + steps, + }, + }, + logs: {stepsLog: [] as any[]}, + }; +} + +// Helper function to setup auto-expand localStorage +function enableAutoExpand() { + localStorage.setItem('actions-view-options', JSON.stringify({ + autoScroll: true, + expandRunning: true, + })); +} + +beforeEach(() => { + // Setup window.config for CSRF token which is needed by fetch.ts + (globalThis as any).window = globalThis.window || {}; + globalThis.window.config = globalThis.window.config || {}; + globalThis.window.config.csrfToken = 'test-csrf-token'; + + // Default mock - needed because unmocked `loadJobData` is called when component is mounted + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(createMockApiResponse()), + } as Response), + ); +}); + +afterEach(() => { + localStorage.removeItem('actions-view-options'); +}); + +test('auto expand works on subsequent loads', async () => { + enableAutoExpand(); + + const wrapper = mount(RepoActionView, {props: createDefaultProps()}); + await wrapper.vm.$nextTick(); + + // Stop the interval timer + if (wrapper.vm.intervalID) { + clearInterval(wrapper.vm.intervalID); + wrapper.vm.intervalID = null; + } + + // Create mock fetch function + const mockResponse = createMockApiResponse([ + {summary: 'Step 1', duration: '1s', status: 'running'}, + ]); + const mockFetchJobData = vi.fn().mockResolvedValue(mockResponse); + + // Reset component state to ensure isFirstLoad = true + wrapper.vm.run.status = '' as any; + wrapper.vm.currentJob.steps = []; + wrapper.vm.currentJobStepsStates = []; + wrapper.vm.loadingAbortController = null; + + // Call the real loadJob method with our mock fetch function + await wrapper.vm.loadJob(mockFetchJobData); + + // First load should work - step should be auto-expanded + expect(wrapper.vm.run.status).toBe('running'); + expect(wrapper.vm.currentJobStepsStates.length).toBe(1); + expect(wrapper.vm.currentJobStepsStates[0].expanded).toBe(true); + + // manually collapse the step + wrapper.vm.currentJobStepsStates[0].expanded = false; + + // Clear abort controller and call loadJob again (isFirstLoad will now be false) + wrapper.vm.loadingAbortController = null; + await wrapper.vm.loadJob(mockFetchJobData); + + // The step should auto-expand on subsequent loads + expect(wrapper.vm.currentJobStepsStates[0].expanded).toBe(true); + + wrapper.unmount(); +}); + +test('auto expand works when step becomes running', async () => { + enableAutoExpand(); + + const wrapper = mount(RepoActionView, {props: createDefaultProps()}); + await wrapper.vm.$nextTick(); + + // Stop the interval timer + if (wrapper.vm.intervalID) { + clearInterval(wrapper.vm.intervalID); + wrapper.vm.intervalID = null; + } + + // Create mock fetch function that returns different responses on each call + let callCount = 0; + const mockFetchJobData = vi.fn().mockImplementation(async () => { + callCount++; + const stepStatus = callCount === 1 ? 'waiting' : 'running'; + + return createMockApiResponse([ + {summary: 'Step 1', duration: '1s', status: stepStatus}, + {summary: 'Step 2', duration: '0s', status: 'waiting'}, + ]); + }); + + // Reset component state + wrapper.vm.run.status = 'unknown' as any; + wrapper.vm.currentJob.steps = []; + wrapper.vm.currentJobStepsStates = []; + wrapper.vm.loadingAbortController = null; + + // First load - step is waiting (using real component loadJob logic) + await wrapper.vm.loadJob(mockFetchJobData); + expect(wrapper.vm.currentJobStepsStates.length).toBe(2); + expect(wrapper.vm.currentJobStepsStates[0].expanded).toBe(false); // Not expanded because step is waiting + + // Clear abort controller and do second load - step becomes running, should auto-expand + wrapper.vm.loadingAbortController = null; + await wrapper.vm.loadJob(mockFetchJobData); + + // The step transitioned to running and should auto-expand even when isFirstLoad = false + expect(wrapper.vm.currentJobStepsStates[0].expanded).toBe(true); + + wrapper.unmount(); +}); diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 2eb221126986c..667c090e119a5 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -26,6 +26,9 @@ type LogLineCommand = { prefix: string, } +// explicitly define job data function type for clarity since we're injecting it from tests +type FetchJobDataFn = (abortController: AbortController) => Promise; + type Job = { id: number; name: string; @@ -318,7 +321,7 @@ export default defineComponent({ await this.loadJobForce(); }, - async fetchJobData(abortController: AbortController) { + async fetchJobData(abortController: AbortController): ReturnType { const logCursors = this.currentJobStepsStates.map((it, idx) => { // cursor is used to indicate the last position of the logs // it's only used by backend, frontend just reads it and passes it back, it and can be any type. @@ -338,14 +341,14 @@ export default defineComponent({ await this.loadJob(); }, - async loadJob() { + async loadJob(fetchJobDataFn?: FetchJobDataFn) { if (this.loadingAbortController) return; const abortController = new AbortController(); this.loadingAbortController = abortController; try { const isFirstLoad = !this.run.status; - const job = await this.fetchJobData(abortController); - if (this.loadingAbortController !== abortController) return; + // use the injected fetchJobDataFn if available (for testing purposes) + const job = await (fetchJobDataFn || this.fetchJobData)(abortController); this.artifacts = job.artifacts || []; this.run = job.state.run; @@ -353,13 +356,18 @@ export default defineComponent({ // sync the currentJobStepsStates to store the job step states for (let i = 0; i < this.currentJob.steps.length; i++) { - const expanded = isFirstLoad && this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running'; + const step = this.currentJob.steps[i]; + const isRunning = step.status === 'running'; + if (!this.currentJobStepsStates[i]) { // initial states for job steps - this.currentJobStepsStates[i] = {cursor: null, expanded}; + const shouldExpand = (isFirstLoad && this.optionAlwaysExpandRunning && isRunning); + this.currentJobStepsStates[i] = {cursor: null, expanded: shouldExpand}; + } else if (this.optionAlwaysExpandRunning && isRunning && !this.currentJobStepsStates[i].expanded) { + // auto-expand running steps on subsequent loads (handles step transitions) + this.currentJobStepsStates[i].expanded = true; } } - // find the step indexes that need to auto-scroll const autoScrollStepIndexes = new Map(); for (const logs of job.logs.stepsLog ?? []) {