2024-09-11 22:34:33 +02:00
// @watch start
// web_src/js/features/comp/**
// web_src/js/features/repo-**
// templates/repo/issue/view_content/*
2025-09-27 13:23:10 +02:00
// routers/web/repo/issue_content_history.go
2024-09-11 22:34:33 +02:00
// @watch end
2024-09-11 21:19:13 +02:00
import { expect } from '@playwright/test' ;
2025-10-11 10:38:48 +02:00
import { test , dynamic_id , login_user } from './utils_e2e.ts' ;
2025-10-03 13:45:08 +02:00
import { screenshot } from './shared/screenshots.ts' ;
2024-09-11 21:19:13 +02:00
2025-01-05 05:17:04 +00:00
test . use ( { user : 'user2' } ) ;
2024-09-11 21:19:13 +02:00
2025-10-11 10:38:48 +02:00
for ( const run of [
{ title : 'JS off' , js : true } ,
{ title : 'JS on' , js : false } ,
] ) {
test . describe ( ` Create issue & comment ` , ( ) = > {
// playwright/valid-title says: [error] Title must be a string
test ( ` ${ run . title } ` , async ( { browser } , workerInfo ) = > {
2025-11-13 17:23:08 +01:00
test . skip ( [ 'Mobile Chrome' ] . includes ( workerInfo . project . name ) , 'Mobile Chrome has trouble clicking Comment button with JS enabled' ) ;
2025-10-11 10:38:48 +02:00
const issueTitle = dynamic_id ( ) ;
const issueContent = dynamic_id ( ) ;
const commentContent = dynamic_id ( ) ;
const context = await login_user ( browser , workerInfo , 'user2' , { javaScriptEnabled : run.js } ) ;
const page = await context . newPage ( ) ;
let response = await page . goto ( '/user2/repo1/issues/new' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
// Create a new issue
await page . getByPlaceholder ( 'Title' ) . fill ( issueTitle ) ;
await page . getByPlaceholder ( 'Leave a comment' ) . fill ( issueContent ) ;
await page . getByRole ( 'button' , { name : 'Create issue' } ) . click ( ) ;
if ( run . js ) {
await expect ( page ) . toHaveURL ( /\/user2\/repo1\/issues\/\d+$/ ) ;
} else {
// NoJS clients end up on a .../comments JSON file and browsers surround it with some HTML
const redirectUrl = await JSON . parse ( await page . locator ( 'body' ) . textContent ( ) ) [ 'redirect' ] ;
response = await page . goto ( redirectUrl ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
}
// Leave a comment
await page . locator ( '#comment-form' ) . getByPlaceholder ( 'Leave a comment' ) . fill ( commentContent ) ;
await page . locator ( '#comment-form button.primary' ) . filter ( { hasText : 'Comment' } ) . click ( ) ;
if ( ! run . js ) {
const redirectUrl = await JSON . parse ( await page . locator ( 'body' ) . textContent ( ) ) [ 'redirect' ] ;
response = await page . goto ( redirectUrl ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
}
// Validate the page contents that actions above made a difference
await expect ( page . locator ( 'h1' ) ) . toContainText ( issueTitle ) ;
await expect ( page . locator ( '.comment' ) . filter ( { hasText : issueContent } ) ) . toHaveCount ( 1 ) ;
await expect ( page . locator ( '.comment' ) . filter ( { hasText : commentContent } ) ) . toHaveCount ( 1 ) ;
} ) ;
} ) ;
}
2025-01-05 05:17:04 +00:00
test ( 'Menu accessibility' , async ( { page } ) = > {
2024-12-30 16:06:18 +01:00
await page . goto ( '/user2/repo1/issues/1' ) ;
await expect ( page . getByLabel ( 'user2 reacted eyes. Remove eyes' ) ) . toBeVisible ( ) ;
await expect ( page . getByLabel ( 'reacted laugh. Remove laugh' ) ) . toBeVisible ( ) ;
await expect ( page . locator ( '#issue-1' ) . getByLabel ( 'Comment menu' ) ) . toBeVisible ( ) ;
await expect ( page . locator ( '#issue-1' ) . getByRole ( 'heading' ) . getByLabel ( 'Add reaction' ) ) . toBeVisible ( ) ;
page . getByLabel ( 'reacted laugh. Remove' ) . click ( ) ;
await expect ( page . getByLabel ( 'user1 reacted laugh. Add laugh' ) ) . toBeVisible ( ) ;
page . getByLabel ( 'user1 reacted laugh.' ) . click ( ) ;
await expect ( page . getByLabel ( 'user1, user2 reacted laugh. Remove laugh' ) ) . toBeVisible ( ) ;
} ) ;
2025-11-05 05:02:52 +01:00
test . describe ( 'Button text replaced by JS' , ( ) = > {
async function testPage ( page , path , closeLabel ) {
await page . goto ( path ) ;
const statusButton = page . locator ( '#status-button' ) ;
const statusButtonIcon = page . locator ( '#status-button svg' ) ;
const commentField = page . locator ( '#comment-form' ) . getByPlaceholder ( 'Leave a comment' ) ;
// Reset issue status before running the test
if ( await statusButton . getByText ( 'Reopen' ) . isVisible ( ) ) await statusButton . click ( ) ;
// Assert that normal Close button text is present
await expect ( statusButton . getByText ( closeLabel ) ) . toBeVisible ( ) ;
await expect ( statusButtonIcon ) . toBeVisible ( ) ;
// Type in some text to make button text change
await commentField . fill ( 'Blah blah' ) ;
await expect ( statusButton . getByText ( 'Close with comment' ) ) . toBeVisible ( ) ;
await expect ( statusButtonIcon ) . toBeVisible ( ) ;
// Close issue/PR and assert that normal Reopen button text is present
await statusButton . click ( ) ;
await expect ( statusButton . getByText ( 'Reopen' ) ) . toBeVisible ( ) ;
await expect ( statusButtonIcon ) . toBeVisible ( ) ;
// Type in some text to make button text change
await commentField . fill ( 'Blah blah' ) ;
await expect ( statusButton . getByText ( 'Reopen with comment' ) ) . toBeVisible ( ) ;
await expect ( statusButtonIcon ) . toBeVisible ( ) ;
return true ;
}
test ( 'Issue' , async ( { page } ) = > {
// All actual expect() are happening in the helper
expect ( await testPage ( page , '/user2/repo1/issues/1' , 'Close issue' ) ) . toBeTruthy ( ) ;
} ) ;
test ( 'PR' , async ( { page } ) = > {
expect ( await testPage ( page , '/user2/repo1/pulls/3' , 'Close pull request' ) ) . toBeTruthy ( ) ;
} ) ;
} ) ;
2025-11-13 17:23:08 +01:00
test ( 'Hyperlink paste behaviour' , async ( { page , isMobile } ) = > {
test . skip ( isMobile , 'Mobile clients seem to have very weird behaviour with this test, which I cannot confirm with real usage' ) ;
2024-09-11 21:19:13 +02:00
await page . goto ( '/user2/repo1/issues/new' ) ;
await page . locator ( 'textarea' ) . click ( ) ;
// same URL
await page . locator ( 'textarea' ) . fill ( 'https://codeberg.org/forgejo/forgejo#some-anchor' ) ;
await page . locator ( 'textarea' ) . press ( 'Shift+Home' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+c' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+v' ) ;
await expect ( page . locator ( 'textarea' ) ) . toHaveValue ( 'https://codeberg.org/forgejo/forgejo#some-anchor' ) ;
// other text
await page . locator ( 'textarea' ) . fill ( 'Some other text' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+a' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+v' ) ;
await expect ( page . locator ( 'textarea' ) ) . toHaveValue ( '[Some other text](https://codeberg.org/forgejo/forgejo#some-anchor)' ) ;
// subset of URL
await page . locator ( 'textarea' ) . fill ( 'https://codeberg.org/forgejo/forgejo#some' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+a' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+v' ) ;
await expect ( page . locator ( 'textarea' ) ) . toHaveValue ( 'https://codeberg.org/forgejo/forgejo#some-anchor' ) ;
// superset of URL
await page . locator ( 'textarea' ) . fill ( 'https://codeberg.org/forgejo/forgejo#some-anchor-on-the-page' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+a' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+v' ) ;
await expect ( page . locator ( 'textarea' ) ) . toHaveValue ( 'https://codeberg.org/forgejo/forgejo#some-anchor' ) ;
// completely separate URL
await page . locator ( 'textarea' ) . fill ( 'http://example.com' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+a' ) ;
await page . locator ( 'textarea' ) . press ( 'ControlOrMeta+v' ) ;
await expect ( page . locator ( 'textarea' ) ) . toHaveValue ( 'https://codeberg.org/forgejo/forgejo#some-anchor' ) ;
2024-10-24 01:07:53 +02:00
await page . locator ( 'textarea' ) . fill ( '' ) ;
2024-09-11 21:19:13 +02:00
} ) ;
2025-01-05 05:17:04 +00:00
test ( 'Always focus edit tab first on edit' , async ( { page } ) = > {
2024-09-11 21:19:13 +02:00
const response = await page . goto ( '/user2/repo1/issues/1' ) ;
2024-10-23 16:22:25 +02:00
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
2024-09-11 21:19:13 +02:00
// Switch to preview tab and save
await page . click ( '#issue-1 .comment-container .context-menu' ) ;
await page . click ( '#issue-1 .comment-container .menu>.edit-content' ) ;
2025-10-13 17:46:35 +02:00
await page . locator ( '#issue-1 .comment-container [data-tab-for=markdown-previewer]' ) . click ( ) ;
2024-09-11 21:19:13 +02:00
await page . click ( '#issue-1 .comment-container .save' ) ;
2024-11-12 21:07:09 +01:00
await page . waitForLoadState ( ) ;
2024-09-11 21:19:13 +02:00
// Edit again and assert that edit tab should be active (and not preview tab)
await page . click ( '#issue-1 .comment-container .context-menu' ) ;
await page . click ( '#issue-1 .comment-container .menu>.edit-content' ) ;
2025-10-13 17:46:35 +02:00
const editTab = page . locator ( '#issue-1 .comment-container [data-tab-for=markdown-writer]' ) ;
const previewTab = page . locator ( '#issue-1 .comment-container [data-tab-for=markdown-previewer]' ) ;
2024-09-11 21:19:13 +02:00
await expect ( editTab ) . toHaveClass ( /active/ ) ;
await expect ( previewTab ) . not . toHaveClass ( /active/ ) ;
2025-10-03 13:45:08 +02:00
await screenshot ( page , page . locator ( '.issue-content-left' ) ) ;
2024-09-11 21:19:13 +02:00
} ) ;
2024-10-24 01:07:53 +02:00
2025-01-17 20:14:28 +00:00
test ( 'Reset content of comment edit field on cancel' , async ( { page } ) = > {
const response = await page . goto ( '/user2/repo1/issues/1' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
const editorTextarea = page . locator ( '[id="_combo_markdown_editor_1"]' ) ;
// Change the content of the edit field
await page . click ( '#issue-1 .comment-container .context-menu' ) ;
await page . click ( '#issue-1 .comment-container .menu>.edit-content' ) ;
await expect ( editorTextarea ) . toHaveValue ( 'content for the first issue' ) ;
await editorTextarea . fill ( 'some random string' ) ;
await expect ( editorTextarea ) . toHaveValue ( 'some random string' ) ;
await page . click ( '#issue-1 .comment-container .edit .cancel' ) ;
// Edit again and assert that the edit field should be reset to the initial content
await page . click ( '#issue-1 .comment-container .context-menu' ) ;
await page . click ( '#issue-1 .comment-container .menu>.edit-content' ) ;
await expect ( editorTextarea ) . toHaveValue ( 'content for the first issue' ) ;
2025-10-03 13:45:08 +02:00
await screenshot ( page , page . locator ( '.issue-content-left' ) ) ;
2025-01-17 20:14:28 +00:00
} ) ;
2025-01-05 05:17:04 +00:00
test ( 'Quote reply' , async ( { page } , workerInfo ) = > {
2024-10-24 01:07:53 +02:00
test . skip ( workerInfo . project . name !== 'firefox' , 'Uses Firefox specific selection quirks' ) ;
const response = await page . goto ( '/user2/repo1/issues/1' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
const editorTextarea = page . locator ( 'textarea.markdown-text-editor' ) ;
// Full quote.
await page . click ( '#issuecomment-1001 .comment-container .context-menu' ) ;
await page . click ( '#issuecomment-1001 .quote-reply' ) ;
await expect ( editorTextarea ) . toHaveValue ( '@user2 wrote in http://localhost:3003/user2/repo1/issues/1#issuecomment-1001:\n\n' +
'> ## [](#lorem-ipsum)Lorem Ipsum\n' +
'> \n' +
'> I would like to say that **I am not appealed** that it took _so long_ for this `feature` to be [created](https://example.com) \\(e^{\\pi i} + 1 = 0\\)\n' +
'> \n' +
'> \\[e^{\\pi i} + 1 = 0\\]\n' +
'> \n' +
'> #1\n' +
'> \n' +
'> ```js\n' +
"> console.log('evil')\n" +
"> alert('evil')\n" +
'> ```\n' +
'> \n' +
2025-07-19 15:03:10 +02:00
'> :+1: :100: [](/user2/repo1/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2)\n' +
'> <img alt="something something" width="500" height="500" src="/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2">\n\n' ) ;
2024-10-24 01:07:53 +02:00
await editorTextarea . fill ( '' ) ;
// Partial quote.
await page . click ( '#issuecomment-1001 .comment-container .context-menu' ) ;
await page . evaluate ( ( ) = > {
const range = new Range ( ) ;
range . setStart ( document . querySelector ( '#issuecomment-1001-content #user-content-lorem-ipsum' ) . childNodes [ 1 ] , 6 ) ;
range . setEnd ( document . querySelector ( '#issuecomment-1001-content p' ) . childNodes [ 1 ] . childNodes [ 0 ] , 7 ) ;
const selection = window . getSelection ( ) ;
// Add range to window selection
selection . addRange ( range ) ;
} ) ;
await page . click ( '#issuecomment-1001 .quote-reply' ) ;
await expect ( editorTextarea ) . toHaveValue ( '@user2 wrote in http://localhost:3003/user2/repo1/issues/1#issuecomment-1001:\n\n' +
'> ## Ipsum\n' +
'> \n' +
'> I would like to say that **I am no**\n\n' ) ;
await editorTextarea . fill ( '' ) ;
// Another partial quote.
await page . click ( '#issuecomment-1001 .comment-container .context-menu' ) ;
await page . evaluate ( ( ) = > {
const range = new Range ( ) ;
range . setStart ( document . querySelector ( '#issuecomment-1001-content p' ) . childNodes [ 1 ] . childNodes [ 0 ] , 7 ) ;
range . setEnd ( document . querySelector ( '#issuecomment-1001-content p' ) . childNodes [ 7 ] . childNodes [ 0 ] , 3 ) ;
const selection = window . getSelection ( ) ;
// Add range to window selection
selection . addRange ( range ) ;
} ) ;
await page . click ( '#issuecomment-1001 .quote-reply' ) ;
await expect ( editorTextarea ) . toHaveValue ( '@user2 wrote in http://localhost:3003/user2/repo1/issues/1#issuecomment-1001:\n\n' +
'> **t appealed** that it took _so long_ for this `feature` to be [cre](https://example.com)\n\n' ) ;
await editorTextarea . fill ( '' ) ;
} ) ;
2025-01-05 05:17:04 +00:00
test ( 'Pull quote reply' , async ( { page } , workerInfo ) = > {
2024-10-24 01:07:53 +02:00
test . skip ( workerInfo . project . name !== 'firefox' , 'Uses Firefox specific selection quirks' ) ;
const response = await page . goto ( '/user2/commitsonpr/pulls/1/files' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
2025-02-06 11:56:09 +00:00
const editorTextarea = page . locator ( 'form.comment-form textarea.markdown-text-editor' ) ;
2024-10-24 01:07:53 +02:00
// Full quote with no reply handler being open.
await page . click ( '.comment-code-cloud .context-menu' ) ;
await page . click ( '.comment-code-cloud .quote-reply' ) ;
await expect ( editorTextarea ) . toHaveValue ( '@user2 wrote in http://localhost:3003/user2/commitsonpr/pulls/1/files#issuecomment-1002:\n\n' +
'> ## [](#lorem-ipsum)Lorem Ipsum\n' +
'> \n' +
'> I would like to say that **I am not appealed** that it took _so long_ for this `feature` to be [created](https://example.com) \\(e^{\\pi i} + 1 = 0\\)\n' +
'> \n' +
'> \\[e^{\\pi i} + 1 = 0\\]\n' +
'> \n' +
'> #1\n' +
'> \n' +
'> ```js\n' +
"> console.log('evil')\n" +
"> alert('evil')\n" +
'> ```\n' +
'> \n' +
2025-07-19 15:03:10 +02:00
'> :+1: :100: [](/user2/commitsonpr/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2)\n' +
'> <img alt="something something" width="500" height="500" src="/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2">\n\n' ) ;
2024-10-24 01:07:53 +02:00
await editorTextarea . fill ( '' ) ;
} ) ;
2025-08-10 23:07:58 +02:00
test ( 'Emoji suggestions' , async ( { page } ) = > {
const response = await page . goto ( '/user2/repo1/issues/1' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
const textarea = page . locator ( '#comment-form textarea[name=content]' ) ;
await textarea . focus ( ) ;
await textarea . pressSequentially ( ':' ) ;
const suggestionList = page . locator ( '#comment-form .suggestions' ) ;
await expect ( suggestionList ) . toBeVisible ( ) ;
const expectedSuggestions = [
{ emoji : '👍' , name : '+1' } ,
{ emoji : '👎' , name : '-1' } ,
{ emoji : '💯' , name : '100' } ,
{ emoji : '🔢' , name : '1234' } ,
{ emoji : '🥇' , name : '1st_place_medal' } ,
{ emoji : '🥈' , name : '2nd_place_medal' } ,
] ;
for ( const { emoji , name } of expectedSuggestions ) {
const item = suggestionList . locator ( ` li:has-text(" ${ name } ") ` ) ;
await expect ( item ) . toContainText ( ` ${ emoji } ${ name } ` ) ;
}
await textarea . pressSequentially ( 'forge' ) ;
await expect ( suggestionList ) . toBeVisible ( ) ;
const item = suggestionList . locator ( ` li:has-text("forgejo") ` ) ;
await expect ( item . locator ( 'img' ) ) . toHaveAttribute ( 'src' , '/assets/img/emoji/forgejo.png' ) ;
} ) ;
2025-09-27 13:23:10 +02:00
2025-10-27 22:04:04 +01:00
test . describe ( 'Comment history' , ( ) = > {
let issueURL = '' ;
test ( 'Deleted items in comment history menu' , async ( { page } ) = > {
const response = await page . goto ( '/user2/repo1/issues/new' ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
// Create a new issue.
await page . getByPlaceholder ( 'Title' ) . fill ( 'Just a title' ) ;
await page . getByPlaceholder ( 'Leave a comment' ) . fill ( 'Hi, have you considered using a rotating fish as logo?' ) ;
await page . getByRole ( 'button' , { name : 'Create issue' } ) . click ( ) ;
await expect ( page ) . toHaveURL ( /\/user2\/repo1\/issues\/\d+$/ ) ;
issueURL = page . url ( ) ;
page . on ( 'dialog' , ( dialog ) = > dialog . accept ( ) ) ;
// Make a change.
const editorTextarea = page . locator ( '[id="_combo_markdown_editor_1"]' ) ;
await page . click ( '.comment-container .context-menu' ) ;
await page . click ( '.comment-container .menu>.edit-content' ) ;
await editorTextarea . fill ( dynamic_id ( ) ) ;
await page . click ( '.comment-container .edit .save' ) ;
// Reload the page so the edited bit is rendered.
await page . reload ( ) ;
await page . getByText ( '• edited' ) . click ( ) ;
await page . click ( '.content-history-menu .item:nth-child(1)' ) ;
await page . getByText ( 'Options' ) . click ( ) ;
await page . getByText ( 'Delete from history' ) . click ( ) ;
await page . getByText ( '• edited' ) . click ( ) ;
await expect ( page . locator ( ".content-history-menu .item s span[data-history-is-deleted='1']" ) ) . toBeVisible ( ) ;
} ) ;
2025-09-27 13:23:10 +02:00
2025-10-27 22:04:04 +01:00
test ( 'Animation spinner' , async ( { page } ) = > {
test . skip ( issueURL === '' , 'previous test failed' ) ;
2025-09-27 13:23:10 +02:00
2025-10-27 22:04:04 +01:00
const response = await page . goto ( issueURL ) ;
expect ( response ? . status ( ) ) . toBe ( 200 ) ;
2025-09-27 13:23:10 +02:00
2025-10-27 22:04:04 +01:00
// Intercept request to get content history list.
let called = false ;
page . on ( 'request' , async ( request ) = > {
if ( ! request . url ( ) . includes ( '/content-history/list' ) ) {
return ;
}
called = true ;
// Assert the dropdown has a animation spinner.
await expect ( page . getByText ( '• edited' ) ) . toHaveClass ( /is-loading/ ) ;
} ) ;
2025-09-27 13:23:10 +02:00
2025-10-27 22:04:04 +01:00
// Open the menu.
await page . getByText ( '• edited' ) . click ( ) ;
// Wait until the menu is visible.
await expect ( page . locator ( '.content-history-menu .item:nth-child(1)' ) ) . toBeVisible ( ) ;
// Expect that there was a request by fomantic.
expect ( called ) . toBeTruthy ( ) ;
// Expect that there is no longer a animation spinner.
await expect ( page . getByText ( '• edited' ) ) . not . toHaveClass ( /is-loading/ ) ;
// Expect that there is no animation spinner after clicking inside the dropdown.
await page . click ( '.content-history-menu .item:nth-child(2)' ) ;
await expect ( page . getByText ( '• edited' ) ) . not . toHaveClass ( /is-loading/ ) ;
await page . click ( '.content-history-detail-dialog .close' ) ;
// Open the menu.
await page . getByText ( '• edited' ) . click ( ) ;
// Wait until the menu is visible.
await expect ( page . locator ( '.content-history-menu .item:nth-child(1)' ) ) . toBeVisible ( ) ;
// Close the menu.
await page . getByText ( '• edited' ) . click ( ) ;
// Expect that there is no animation spinner.
await expect ( page . getByText ( '• edited' ) ) . not . toHaveClass ( /is-loading/ ) ;
} ) ;
2025-09-27 13:23:10 +02:00
} ) ;