Move the e2e suite to Go and playwright-go (#2180)

* Move the e2e suite to Go and playwright-go

The seven playwright tests in `frontend/e2e` become twenty in `e2e/`, a
separate Go module driving the same browsers through playwright-go. The
npm project, its lockfile entries, its prettier config and
`Dockerfile.e2e` go with it, leaving `frontend/` a single-member
workspace.

The suite covers posting with markdown, replying and the nesting that
implies, editing inside the deadline and the backend refusing one outside
it, deleting, voting with the optimistic score observed mid-flight and
rolled back on failure, changing the sort, collapse persistence across a
reload, dev, anonymous and email sign-in end to end, the profile iframe,
and the two scripts that render into the host page rather than the
widget's own frame.

The rendering tests run in chromium, firefox and webkit. The rest sign in,
sign-in needs the dev oauth2 provider, and reaching that by name from the
host is chromium-only, so they run there alone.

`compose-e2e-test.yml` runs remark42, a second instance with a short edit
window so that path does not need a five-minute test, and mailpit, which
catches the email verification message the suite reads back. Everything
binds to the loopback interface: the stack holds a known secret and an
admin shared id, and `go test` can start it unattended. The tests run on
the host rather than in a container.

Three settings there exist for the tests rather than for realism.
`REMARK_URL` uses a hostname because the dev oauth2 server binds whatever
host it reads out of it, and a loopback bind inside a container cannot be
published. `UPDATE_LIMIT` is raised because the default of 0.5/sec rejects
any test posting twice in a row. The suite also paces its own `/auth/`
calls, which are capped at 2/sec by a bare literal in `rest.go` rather
than by a setting.

Each test gets its own comment thread from a query string on the demo
page, so nothing has to reset the database between runs.

CI gains a vet and lint job for the module, since the build tag keeps it
out of a plain `go test ./...`, and uploads a browser trace for any test
that fails.

`e2e/README.md` carries the rest: how to run it, what the stack is for,
and the widget behaviour the assertions have to work around.

* Update golangci-lint to 2.13.1 in the backend workflow

The pin sat three minors behind what the linter installs locally, so CI
checked the backend with an older set of rules than anyone running it by
hand. 2.10.1 also fetches its config schema over the network on every
`config verify`, which is a failure mode with no bearing on the code.

Both targets are clean on 2.13.1, `backend/app` and the memory_store
example.
This commit is contained in:
Dmitry Verkhoturov
2026-08-21 17:53:12 -05:00
committed by GitHub
parent 1bb002348a
commit ff77f41a3a
29 changed files with 1820 additions and 406 deletions
-4
View File
@@ -1,7 +1,3 @@
/.vscode/
/.idea/
# e2e tests arficats
/e2e/playwright-report/
/e2e/playwright/.cache/
/e2e/test-results/
+1 -3
View File
@@ -7,14 +7,12 @@ Non-obvious constraints in the frontend toolchain and widget. Read before bumpin
CI staying green does **not** mean every pin is consistent — `.nvmrc` in particular is never read by CI, so it can silently drift. After changing the node or pnpm version, grep the whole repo and update every one of these, not just the ones CI exercises:
- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`
- `frontend/Dockerfile.e2e` — `FROM mcr.microsoft.com/playwright:vX.Y.Z-noble` **and** `corepack prepare pnpm@X.Y.Z`
- `site/Dockerfile`, `site/Dockerfile.dev` — `FROM node:X-alpine` (site uses yarn, not pnpm)
- `frontend/.nvmrc`, `site/.nvmrc` — not read by CI at all; only matters to a human running `nvm use` locally. This is the one that drifted unnoticed: it sat at `16` through the whole node-20 migration because nothing red ever pointed at it.
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json` — `frontend/e2e/package.json` has none) and `frontend/apps/remark42/package.json`'s `engines` block
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json`) and `frontend/apps/remark42/package.json`'s `engines` block
- `pnpm/action-setup@vN` blocks in `.github/workflows/ci-frontend.yml` (5) and `release.yml` (2) — pin `version:` to the **exact** patch (e.g. `10.10.0`), matching `packageManager`, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`
- `site/package.json`'s `engines.node` and `engines.yarn` (site uses yarn, so its `packageManager` moves independently)
- `frontend/e2e/package.json`'s `@playwright/test`/`playwright` versions must match `frontend/Dockerfile.e2e`'s base image tag exactly, or the e2e container's bundled browser revision mismatches what the npm package expects.
When bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.
-15
View File
@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.61.1-noble
ENV CI true
WORKDIR /frontend
COPY ./package.json ./pnpm-workspace.yaml ./pnpm-lock.yaml /frontend/
COPY ./e2e/package.json /frontend/e2e/
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate && pnpm install
COPY ./e2e/playwright.config.ts /frontend/e2e/
COPY ./e2e/tests /frontend/e2e/tests/
WORKDIR /frontend/e2e
CMD pnpm test
-4
View File
@@ -1,4 +0,0 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@remark42/tests",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"author": "Paul Mineev <paul@mineev.me>",
"license": "MIT",
"devDependencies": {
"@playwright/test": "1.61.1",
"@types/node": "^26.0.1",
"nanoid": "^5.1.16",
"playwright": "1.61.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
-107
View File
@@ -1,107 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://remark42:8080' : 'http://127.0.0.1:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
}
export default config
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('prettier').Config} */
module.exports = {
singleQuote: true,
semi: false,
arrowParens: 'always',
trailingComma: 'es5',
printWidth: 120,
}
-83
View File
@@ -1,83 +0,0 @@
import { test, expect, type Page } from '@playwright/test'
// the parent page sets color-scheme on the iframe element from the theme param. if the iframe
// document does not carry the same color-scheme before its bundle runs, the canvas is painted
// opaque white instead of staying transparent. block the bundle to freeze the document in that
// pre-script state and assert the inline head script has already applied the scheme.
test.describe('Iframe color scheme', () => {
test.beforeEach(async ({ page }) => {
await page.route(/remark\.m?js$/, (route) => route.abort())
})
const cases = [
{ name: 'dark theme', query: '?site_id=remark&theme=dark', expected: 'dark' },
{ name: 'light theme', query: '?site_id=remark&theme=light', expected: 'light' },
{ name: 'no theme falls back to light', query: '?site_id=remark', expected: 'light' },
]
for (const { name, query, expected } of cases) {
test(name, async ({ page }) => {
await page.goto(`/web/iframe.html${query}`)
const inline = await page.evaluate(() => document.documentElement.style.colorScheme)
expect(inline).toBe(expected)
const computed = await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)
expect(computed).toBe(expected)
})
}
})
// browsers paint a default surface for an iframe before its document is parsed, and that surface
// is opaque when the element carries a color-scheme the document does not have yet. WebKit shows
// it as a white flash on dark host pages. the parent keeps the iframe hidden until the document
// reports itself inited, so the surface is never presented.
test.describe('Iframe reveal', () => {
// REVEAL_TIMEOUT in app/utils/create-iframe.ts. the fallback timer starts when the
// iframe is created, during page load, so any assertion with a deadline at or past
// this value can be satisfied by the fallback alone and says nothing about the
// message path. bound the message-path assertions well under it.
const REVEAL_TIMEOUT = 5000
const MESSAGE_REVEAL_BUDGET = 1500
const visibility = (page: Page) =>
page.evaluate(() => {
const iframe = document.querySelector<HTMLIFrameElement>('#remark42 iframe')
return iframe ? iframe.style.visibility : 'no-iframe'
})
test('stays hidden until the document reports inited', async ({ page }) => {
await page.route(/\/web\/iframe\.html/, (route) => route.abort())
const start = Date.now()
await page.goto('/web/')
await page.waitForSelector('#remark42 iframe', { state: 'attached' })
expect(await visibility(page)).toBe('hidden')
// a slow run could have let the fallback fire, which would make the assertion
// above pass or fail for the wrong reason. fail loudly instead of flaking.
expect(Date.now() - start).toBeLessThan(REVEAL_TIMEOUT)
})
// must reveal from the inited message, not the fallback: a broken message listener would
// leave the widget invisible for 5s on every load. the fallback timer starts when the
// iframe is created, partway through goto(), so bounding only the poll leaves the
// navigation window unmeasured. time the whole thing.
test('is revealed by the inited message, well before the fallback', async ({ page }) => {
const start = Date.now()
await page.goto('/web/')
await expect.poll(() => visibility(page), { timeout: MESSAGE_REVEAL_BUDGET }).toBe('visible')
expect(Date.now() - start).toBeLessThan(REVEAL_TIMEOUT)
await expect(page.locator('#remark42 iframe')).toBeVisible()
})
// the aborted document never reports its height, so the iframe box stays empty and
// toBeVisible() would fail on geometry. assert the property the fallback actually sets.
test('is revealed by the timeout when inited never arrives', async ({ page }) => {
await page.route(/\/web\/iframe\.html/, (route) => route.abort())
await page.goto('/web/')
await page.waitForSelector('#remark42 iframe', { state: 'attached' })
await expect.poll(() => visibility(page), { timeout: REVEAL_TIMEOUT * 2 }).toBe('visible')
})
})
-30
View File
@@ -1,30 +0,0 @@
import { test } from '@playwright/test'
import { nanoid } from 'nanoid'
import * as path from 'path'
test.describe('Post comment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/web/')
})
test('as dev user', async ({ page, browserName }) => {
const iframe = page.frameLocator('iframe[name]')
await iframe.locator('text=Sign In').click()
const [authPage] = await Promise.all([
page.waitForEvent('popup'),
iframe.locator("[title='Sign In with Dev']").click(),
])
await authPage.locator('text=Authorize').click()
// triggers tab visibility and enables widget to re-render with auth state
await page.press('iframe[name]', 'Tab')
await iframe.locator('textarea').click()
const message = `Hello world! ${nanoid()}`
await iframe.locator('textarea').type(message)
await iframe.locator('text=Send').click()
// checks if comment was posted
iframe.locator(`text=${message}`).first()
await page.reload()
// checks if saved comment is visible
iframe.locator(`text=${message}`).first()
})
})
-83
View File
@@ -351,27 +351,6 @@ importers:
specifier: '>=5.2.6 <6.0.0'
version: 5.2.6(tslib@2.8.1)(webpack-cli@4.10.0)(webpack@5.108.3)
e2e:
devDependencies:
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
'@types/node':
specifier: ^26.0.1
version: 26.0.1
nanoid:
specifier: ^5.1.16
version: 5.1.16
playwright:
specifier: 1.61.1
version: 1.61.1
ts-node:
specifier: ^10.9.2
version: 10.9.2(@swc/core@1.2.205)(@types/node@26.0.1)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@adobe/css-tools@4.5.0':
@@ -1542,11 +1521,6 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -3472,11 +3446,6 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4570,11 +4539,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
hasBin: true
nanospinner@1.2.2:
resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==}
@@ -4885,16 +4849,6 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -7938,10 +7892,6 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@polka/url@1.0.0-next.29': {}
'@prefresh/babel-plugin@0.4.4': {}
@@ -10278,9 +10228,6 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -11544,8 +11491,6 @@ snapshots:
nanoid@3.3.18: {}
nanoid@5.1.16: {}
nanospinner@1.2.2:
dependencies:
picocolors: 1.1.1
@@ -11847,14 +11792,6 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss-attribute-case-insensitive@5.0.2(postcss@8.5.26):
@@ -13107,26 +13044,6 @@ snapshots:
optionalDependencies:
'@swc/core': 1.2.205
ts-node@10.9.2(@swc/core@1.2.205)(@types/node@26.0.1)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 26.0.1
acorn: 8.17.0
acorn-walk: 8.3.5
arg: 4.1.3
create-require: 1.1.1
diff: 9.0.0
make-error: 1.3.6
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.2.205
tsconfig-paths-webpack-plugin@3.5.2:
dependencies:
chalk: 4.1.2
-1
View File
@@ -1,3 +1,2 @@
packages:
- "apps/*"
- "e2e"