forgejo/tests/e2e/issue-comment.test.e2e.ts
Gusted d0a6f93f9e fix: avoid jumping to begin of page on edit comment action (#9645)
When you edit a comment and the comment already has a markdown editor,
then the code will click on the 'Write' tab, in case you canceled
editting the comment when you were at the 'Preview' tab. In
forgejo/forgejo#2681 I added `href="#"` to the tab items, this causes
that when the 'Write' tab is being clicked by the code the page is
jumped the beginning of the page.

Instead of being clever and trying to make this item interactive via
another way or via javascript avoid this jumping, we do better and make
this element a button. This item is not a link, it's a button that will
perform a action. This entirely avoids the issue of jumping and it's
still interactive.

Resolves forgejo/forgejo#9542

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9645
Reviewed-by: Beowulf <beowulf@beocode.eu>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
2025-10-13 17:46:35 +02:00

322 lines
15 KiB
TypeScript

// @watch start
// web_src/js/features/comp/**
// web_src/js/features/repo-**
// templates/repo/issue/view_content/*
// routers/web/repo/issue_content_history.go
// @watch end
import {expect} from '@playwright/test';
import {test, dynamic_id, login_user} from './utils_e2e.ts';
import {screenshot} from './shared/screenshots.ts';
test.use({user: 'user2'});
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) => {
test.skip(['Mobile Chrome', 'Mobile Safari'].includes(workerInfo.project.name), 'Mobile Chrome has trouble clicking Comment button with JS enabled, Mobile Safari is flaky and only passes on retry');
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);
});
});
}
test('Menu accessibility', async ({page}) => {
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();
});
test('Hyperlink paste behaviour', async ({page}, workerInfo) => {
test.skip(['Mobile Safari', 'Mobile Chrome', 'webkit'].includes(workerInfo.project.name), 'Mobile clients seem to have very weird behaviour with this test, which I cannot confirm with real usage');
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');
await page.locator('textarea').fill('');
});
test('Always focus edit tab first on edit', async ({page}) => {
const response = await page.goto('/user2/repo1/issues/1');
expect(response?.status()).toBe(200);
// 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');
await page.locator('#issue-1 .comment-container [data-tab-for=markdown-previewer]').click();
await page.click('#issue-1 .comment-container .save');
await page.waitForLoadState();
// 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');
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]');
await expect(editTab).toHaveClass(/active/);
await expect(previewTab).not.toHaveClass(/active/);
await screenshot(page, page.locator('.issue-content-left'));
});
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');
await screenshot(page, page.locator('.issue-content-left'));
});
test('Quote reply', async ({page}, workerInfo) => {
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' +
'> :+1: :100: [![hi there](/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2)](/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');
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('');
});
test('Pull quote reply', async ({page}, workerInfo) => {
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);
const editorTextarea = page.locator('form.comment-form textarea.markdown-text-editor');
// 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' +
'> :+1: :100: [![hi there](/attachments/3f4f4016-877b-46b3-b79f-ad24519a9cf2)](/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');
await editorTextarea.fill('');
});
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');
});
test('Comment history', 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+$/);
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();
});