diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 0a63f566e0..7aa52ddd4c 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -31,8 +31,9 @@ import ( ) const ( - tplListActions base.TplName = "repo/actions/list" - tplViewActions base.TplName = "repo/actions/view" + tplListActions base.TplName = "repo/actions/list" + tplListActionsInner base.TplName = "repo/actions/list_inner" + tplViewActions base.TplName = "repo/actions/view" ) type Workflow struct { @@ -67,6 +68,8 @@ func List(ctx *context.Context) { curWorkflow := ctx.FormString("workflow") ctx.Data["CurWorkflow"] = curWorkflow + listInner := ctx.FormBool("list_inner") + var workflows []Workflow if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { ctx.ServerError("IsEmpty", err) @@ -250,7 +253,11 @@ func List(ctx *context.Context) { ctx.Data["Page"] = pager ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 - ctx.HTML(http.StatusOK, tplListActions) + if listInner { + ctx.HTML(http.StatusOK, tplListActionsInner) + } else { + ctx.HTML(http.StatusOK, tplListActions) + } } // loadIsRefDeleted loads the IsRefDeleted field for each run in the list. diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 263530f9a7..d973e6f3b2 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -4,89 +4,38 @@
{{template "base/alert" .}} - {{if .HasWorkflowsOrRuns}} -
- -
- - - {{if $.CurWorkflowDispatch}} - {{template "repo/actions/dispatch" .}} - {{end}} - - {{template "repo/actions/runs_list" .}} -
+ {{/* Refresh the list every interval (30s) unless the document isn't visible or a dropdown is open; refresh + if visibility changes as well. simulate-polling-interval is a custom event used for e2e tests to mimic + the polling interval and should be defined identically to the `every` clause for accurate testing. */}} +
+ {{template "repo/actions/list_inner" .}}
- {{else}} - {{template "repo/actions/no_workflows" .}} - {{end}}
+ + + {{template "base/footer" .}} diff --git a/templates/repo/actions/list_inner.tmpl b/templates/repo/actions/list_inner.tmpl new file mode 100644 index 0000000000..088d7d8454 --- /dev/null +++ b/templates/repo/actions/list_inner.tmpl @@ -0,0 +1,85 @@ +{{if .HasWorkflowsOrRuns}} +
+
+ +
+
+ + + {{if $.CurWorkflowDispatch}} + {{template "repo/actions/dispatch" .}} + {{end}} + + {{template "repo/actions/runs_list" .}} +
+
+{{else}} + {{template "repo/actions/no_workflows" .}} +{{end}} diff --git a/tests/e2e/actions.test.e2e.ts b/tests/e2e/actions.test.e2e.ts index 4e93b89ee0..0293d6b60c 100644 --- a/tests/e2e/actions.test.e2e.ts +++ b/tests/e2e/actions.test.e2e.ts @@ -9,10 +9,27 @@ // routers/web/repo/actions/** // @watch end -import {expect} from '@playwright/test'; +import {expect, type Page, type TestInfo} from '@playwright/test'; import {save_visual, test} from './utils_e2e.ts'; const workflow_trigger_notification_text = 'This workflow has a workflow_dispatch event trigger.'; + +async function dispatchSuccess(page: Page, testInfo: TestInfo) { + test.skip(testInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); + + await page.locator('#workflow_dispatch_dropdown>button').click(); + + await page.fill('input[name="inputs[string2]"]', 'abc'); + await save_visual(page); + await page.locator('#workflow-dispatch-submit').click(); + + await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible(); + + await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible(); + await save_visual(page); +} + test.describe('Workflow Authenticated user2', () => { test.use({user: 'user2'}); @@ -50,20 +67,10 @@ test.describe('Workflow Authenticated user2', () => { await save_visual(page); }); + // no assertions as the login in this test case is extracted for reuse + // eslint-disable-next-line playwright/expect-expect test('dispatch success', async ({page}, testInfo) => { - test.skip(testInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383'); - await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); - - await page.locator('#workflow_dispatch_dropdown>button').click(); - - await page.fill('input[name="inputs[string2]"]', 'abc'); - await save_visual(page); - await page.locator('#workflow-dispatch-submit').click(); - - await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible(); - - await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible(); - await save_visual(page); + await dispatchSuccess(page, testInfo); }); }); @@ -73,3 +80,127 @@ test('workflow dispatch box not available for unauthenticated users', async ({pa await expect(page.locator('body')).not.toContainText(workflow_trigger_notification_text); await save_visual(page); }); + +async function completeDynamicRefresh(page: Page) { + // Ensure that the reloading indicator isn't active, indicating that dynamic refresh is done. + await expect(page.locator('#reloading-indicator')).not.toHaveClass(/(^|\s)is-loading(\s|$)/); +} + +async function simulatePollingInterval(page: Page) { + // In order to simulate the background page sitting around for > 30s, a custom event `simulate-polling-interval` is + // fired into the document to mimic the polling interval expiring -- although this isn't a perfectly great E2E test + // with this kind of mimicry, it's better than having multiple >30s execution-time tests. + await page.evaluate(() => { + document.dispatchEvent(new Event('simulate-polling-interval')); + }); + await completeDynamicRefresh(page); +} + +test.describe('workflow list dynamic refresh', () => { + test.use({user: 'user2'}); + + test('refreshes on visibility change', async ({page}, testInfo) => { + // Test operates by creating two pages; one which is sitting idle on the workflows list (backgroundPage), and one + // which triggers a workflow dispatch. Then a document visibilitychange event is fired on the background page to + // mimic a user returning to the tab on their browser, which should trigger the workflow list to refresh and display + // the newly dispatched workflow from the other page. + + const backgroundPage = await page.context().newPage(); + await backgroundPage.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); + + // Mirror the `Workflow Authenticated user2 > dispatch success` test: + await dispatchSuccess(page, testInfo); + const latestDispatchedRun = await page.locator('.run-list>:first-child .flex-item-body>b').textContent(); + expect(latestDispatchedRun).toMatch(/^#/); // workflow ID, eg. "#53" + + // Synthetically trigger a visibilitychange event, as if we were returning to backgroundPage: + await backgroundPage.evaluate(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + await completeDynamicRefresh(page); + await expect(backgroundPage.locator('.run-list>:first-child .flex-item-body>b', {hasText: latestDispatchedRun})).toBeVisible(); + await save_visual(backgroundPage); + }); + + test('refreshes on interval', async ({page}, testInfo) => { + // Test operates by creating two pages; one which is sitting idle on the workflows list (backgroundPage), and one + // which triggers a workflow dispatch. After the polling, the page should refresh and show the newly dispatched + // workflow from the other page. + + const backgroundPage = await page.context().newPage(); + await backgroundPage.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); + + // Mirror the `Workflow Authenticated user2 > dispatch success` test: + await dispatchSuccess(page, testInfo); + const latestDispatchedRun = await page.locator('.run-list>:first-child .flex-item-body>b').textContent(); + expect(latestDispatchedRun).toMatch(/^#/); // workflow ID, eg. "#53" + + await simulatePollingInterval(backgroundPage); + await expect(backgroundPage.locator('.run-list>:first-child .flex-item-body>b', {hasText: latestDispatchedRun})).toBeVisible(); + await save_visual(backgroundPage); + }); + + test('post-refresh the dropdowns continue to operate', async ({page}, testInfo) => { + // Verify that after the page is dynamically refreshed, the 'Actor', 'Status', and 'Run workflow' dropdowns work + // correctly -- that the htmx morph hasn't messed up any JS event handlers. + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); + + // Mirror the `Workflow Authenticated user2 > dispatch success` test -- this creates data for the 'Actor' dropdown + await dispatchSuccess(page, testInfo); + + // Perform a dynamic refresh before checking the functionality of each dropdown. + await simulatePollingInterval(page); + + // Workflow run dialog + await expect(page.locator('input[name="inputs[string2]"]')).toBeHidden(); + await page.locator('#workflow_dispatch_dropdown>button').click(); + await expect(page.locator('input[name="inputs[string2]"]')).toBeVisible(); + await page.locator('#workflow_dispatch_dropdown>button').click(); + + // Status dropdown + await expect(page.getByText('Waiting')).toBeHidden(); + await expect(page.getByText('Failure')).toBeHidden(); + await page.locator('#status_dropdown').click(); + await expect(page.getByText('Waiting')).toBeVisible(); + await expect(page.getByText('Failure')).toBeVisible(); + + // Actor dropdown + await expect(page.getByText('All actors')).toBeHidden(); + await page.locator('#actor_dropdown').click(); + await expect(page.getByText('All Actors')).toBeVisible(); + }); + + test('refresh does not break interacting with open drop-downs', async ({page}, testInfo) => { + // Verify that if the polling refresh occurs while interacting with any multi-step dropdown on the page, the + // multi-step interaction continues to be visible and functional. This is implemented by preventing the refresh, + // but that isn't the subject of the test here -- as long as the dropdown isn't broken by the refresh, that's fine. + await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0'); + + // Mirror the `Workflow Authenticated user2 > dispatch success` test -- this creates data for the 'Actor' dropdown + await dispatchSuccess(page, testInfo); + + // Workflow run dialog + await expect(page.locator('input[name="inputs[string2]"]')).toBeHidden(); + await page.locator('#workflow_dispatch_dropdown>button').click(); + await expect(page.locator('input[name="inputs[string2]"]')).toBeVisible(); + await simulatePollingInterval(page); + await expect(page.locator('input[name="inputs[string2]"]')).toBeVisible(); + + // Status dropdown + await expect(page.getByText('Waiting')).toBeHidden(); + await expect(page.getByText('Failure')).toBeHidden(); + await page.locator('#status_dropdown').click(); + await expect(page.getByText('Waiting')).toBeVisible(); + await expect(page.getByText('Failure')).toBeVisible(); + await simulatePollingInterval(page); + await expect(page.getByText('Waiting')).toBeVisible(); + await expect(page.getByText('Failure')).toBeVisible(); + + // Actor dropdown + await expect(page.getByText('All actors')).toBeHidden(); + await page.locator('#actor_dropdown').click(); + await expect(page.getByText('All Actors')).toBeVisible(); + await simulatePollingInterval(page); + await expect(page.getByText('All Actors')).toBeVisible(); + }); +});