test: puppeteer -> playwright

This commit is contained in:
EnixCoda 2026-01-17 22:39:40 +08:00
parent 5a687cb9f5
commit 7560895fff
43 changed files with 548 additions and 1324 deletions

View file

@ -5,7 +5,7 @@
}, },
"overrides": [ "overrides": [
{ {
"files": ["webpack.config.ts", "*.tsx?"], "files": ["webpack.config.ts", "**/*.ts", "**/*.tsx"],
"excludedFiles": ["*.d.ts"], "excludedFiles": ["*.d.ts"],
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",

View file

@ -22,8 +22,6 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline

View file

@ -47,17 +47,32 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline
- name: Install Playwright Browsers
run: npx playwright install chromium
- name: E2E Test - name: E2E Test
uses: mymindstorm/puppeteer-headful@8f745c770f7f4c0f9f332d7c43a775f90e53779a
env: env:
CI: 'true' CI: 'true'
run: xvfb-run yarn test
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: with:
args: yarn test name: playwright-report
path: playwright-report/
retention-days: 30
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 30
unit-test: unit-test:
needs: build needs: build
@ -86,8 +101,6 @@ jobs:
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline yarn --ignore-platform --ignore-engines --frozen-lockfile --prefer-offline

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ dist-firefox
yarn-error.log yarn-error.log
/vscode-icons /vscode-icons
firefox-profile firefox-profile
/test-results
/playwright-report

View file

@ -1,12 +0,0 @@
{
"env": {
"jest": true
},
"overrides": [
{
"files": ["*.ts"],
"excludedFiles": ["*.d.ts"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"]
}
]
}

View file

@ -1,2 +0,0 @@
// TS files cannot be transformed without this babel config
module.exports = require('../babel.config')

View file

@ -1,49 +0,0 @@
/**
* Confirm basic behaviors of puppeteer assertions
*/
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, expectToReject } from '../utils'
describe(`in random page`, () => {
beforeAll(() => page.goto(testURL`https://google.com`))
it('wait for hidden non-exist element should resolve null', async () => {
expect(
await page.waitForSelector('.non-exist-element', { hidden: true, timeout: 1000 }),
).toBeNull()
})
it('wait for exist element', async () => {
expectToFind('*')
})
it('wait for exist element', async () => {
expectToNotFind('.non-exist-element')
})
it('wait for non-exist element reject should throw', async () => {
await expectToReject(page.waitForSelector('.non-exist-element', { timeout: 1000 }))
})
// Cases below are expected to fail to show how async test works
// // This is expected to fail!
// it('wait for non-exist element reject should not throw', async () => {
// await expect(
// page.waitForSelector('.non-exist-element', { timeout: 1000 }),
// ).rejects.not.toThrow()
// })
// // This is expected to fail!
// it('wait for non-exist element should resolve throw', async () => {
// await expect(page.waitForSelector('.non-exist-element', { timeout: 1000 })).resolves.toThrow()
// })
// // This is expected to fail!
// it('wait for non-exist element should resolve not throw', async () => {
// await expect(
// page.waitForSelector('.non-exist-element', { timeout: 1000 }),
// ).resolves.not.toThrow()
// })
})

View file

@ -1,15 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { getTextContent, sleep } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/GitakoExtension/test-empty`))
it('should render error message', async () => {
await sleep(5000)
expect(await getTextContent(selectors.gitako.errorMessage)).toBe(
'This project seems to be empty.',
)
})
})

View file

@ -1,20 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, sleep, waitForRedirect } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('expand to target on load and after redirect', async () => {
await sleep(3000)
// Expect Gitako sidebar to have expanded src to see contents
await expectToFind(selectors.gitako.fileItemOf('src/components'))
await page.click(selectors.github.fileListItemLinkOf('components'))
await waitForRedirect()
// Expect Gitako sidebar to have expanded components and see contents
await expectToFind(selectors.gitako.fileItemOf('src/components/Gitako.tsx'))
})
})

View file

@ -1,10 +0,0 @@
import { testURL } from '../testURL'
import { expectToNotFind } from '../utils'
describe(`in GitHub homepage`, () => {
beforeAll(() => page.goto(testURL`https://github.com`))
it('should not render Gitako', async () => {
await expectToNotFind('.gitako-side-bar .gitako-side-bar-body-wrapper')
})
})

View file

@ -1,25 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/commits/develop`))
it('should not break go back in history', async () => {
for (let i = 0; i < 3; i++) {
const commitLinks = await page.$$(selectors.github.commitLinks)
if (commitLinks.length < 2) throw new Error(`No enough commits`)
commitLinks[i].click()
await waitForRedirect()
await expectToFind(selectors.github.commitPage)
await sleep(1000)
page.goBack()
await sleep(1000)
// The selector for file content
await expectToNotFind(selectors.github.commitPage)
}
})
})

View file

@ -1,28 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind, expectToNotFind, sleep, waitForRedirect } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('should not break go back in history', async () => {
for (let i = 0; i < 3; i++) {
const fileItems = await page.$$(selectors.github.fileListItemFileLinks)
if (fileItems.length < 2) throw new Error(`No enough files`)
await waitForRedirect(async () => {
await fileItems[i].click()
})
await expectToFind(selectors.github.fileContent)
await sleep(1000)
page.goBack()
await sleep(1000)
// The selector for file content
await expectToNotFind(selectors.github.fileContent)
}
})
})

View file

@ -1,39 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import {
collapseFloatModeSidebar,
expandFloatModeSidebar,
getTextContent,
patientClick,
sleep,
waitForRedirect,
} from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`))
it('should work with PJAX', async () => {
await sleep(3000)
await expandFloatModeSidebar()
await patientClick(selectors.gitako.fileItemOf('src/analytics.ts'))
await waitForRedirect()
await collapseFloatModeSidebar()
await page.click(selectors.github.navBarItemIssues)
await waitForRedirect()
await page.click(selectors.github.navBarItemPulls)
await waitForRedirect()
page.goBack()
await sleep(1000)
page.goBack()
await sleep(1000)
expect(await getTextContent(selectors.github.breadcrumbFileName)).toBe('/analytics.ts')
})
})

View file

@ -1,34 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import {
expandFloatModeSidebar,
expectToFind,
expectToNotFind,
patientClick,
sleep,
waitForRedirect,
} from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/tree/test/multiple-changes`))
it('should work with PJAX', async () => {
await sleep(3000)
await expandFloatModeSidebar()
await patientClick(selectors.gitako.fileItemOf('.babelrc'))
await waitForRedirect()
await expectToFind(selectors.github.fileContent)
await waitForRedirect(async () => {
await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue of puppeteer or jest
page.goBack()
})
// The selector for file content
await expectToNotFind(selectors.github.fileContent)
})
})

View file

@ -1,37 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expandFloatModeSidebar, expectToFind, expectToNotFind, scroll } from '../utils'
jest.retryTimes(3)
describe(`in Gitako project page`, () => {
beforeAll(() =>
page.goto(
testURL`https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each`,
),
)
it('should render Gitako', async () => {
await expectToFind(selectors.gitako.bodyWrapper)
})
it('should render file list', async () => {
await expectToFind(selectors.gitako.fileItem)
})
it('should render while scroll', async () => {
await expandFloatModeSidebar()
const filesEle = await page.waitForSelector(selectors.gitako.files)
// node of tsconfig.json should NOT be rendered before scroll down
await expectToNotFind(selectors.gitako.fileItemOf('tsconfig.json'))
const box = await filesEle?.boundingBox()
if (box) {
await page.mouse.move(box.x + 40, box.y + 40)
await scroll({ totalDistance: 10000, stepDistance: 100 })
// node of tsconfig.json should be rendered now
await expectToFind(selectors.gitako.fileItemOf('tsconfig.json'))
}
})
})

View file

@ -1,15 +0,0 @@
import { selectors } from '../selectors'
import { testURL } from '../testURL'
import { expectToFind } from '../utils'
describe(`in Gitako project page`, () => {
beforeAll(() => page.goto(testURL`https://github.com/EnixCoda/Gitako/pull/71`))
it('should render Gitako', async () => {
await expectToFind(selectors.gitako.bodyWrapper)
})
it('should render file list', async () => {
await expectToFind(selectors.gitako.fileItem)
})
})

View file

@ -1 +0,0 @@
declare var page: Puppeteer.Page

View file

@ -1,185 +0,0 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/6j/kp9yt47x4872_k7d4gdjbwfm0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: null,
// The directory where Jest should output its coverage files
// coverageDirectory: null,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: null,
// A path to a custom dependency extractor
// dependencyExtractor: null,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: null,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
maxWorkers: 1,
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'jest-puppeteer',
// Run tests from one or more projects
// projects: null,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: null,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: null,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '.d.ts$'],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: null,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
testTimeout: 30 * 1000,
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: null,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: null,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

View file

@ -1,7 +0,0 @@
const baseConfig = require('./jest.config')
module.exports = {
...baseConfig,
testMatch: [...baseConfig.testMatch, '**/__tests__/cases/**/*.ts?(x)'],
setupFilesAfterEnv: ['<rootDir>/setup.ts'],
}

View file

@ -1,4 +0,0 @@
import * as Puppeteer from 'puppeteer'
export as namespace Puppeteer
export = Puppeteer

View file

@ -1 +0,0 @@
jest.retryTimes(3)

View file

@ -1,10 +0,0 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"exclude": ["tsconfig.json"],
"compilerOptions": {
"target": "ES5",
"module": "CommonJS",
"baseUrl": null
}
}

View file

@ -1,137 +0,0 @@
export async function expectToResolve<T>(promise: Promise<T>) {
const pass = jest.fn()
await promise.then(pass)
expect(pass).toHaveBeenCalled()
}
export async function expectToReject<T>(promise: Promise<T>) {
const pass = jest.fn()
await promise.catch(pass)
expect(pass).toHaveBeenCalled()
}
export async function expectToFind(selector: string) {
await expectToResolve(page.waitForSelector(selector, { timeout: 2000 }))
}
export async function expectToNotFind(selector: string) {
await expectToReject(page.waitForSelector(selector, { timeout: 1000 }))
}
export function sleep(timeout: number) {
return new Promise(resolve => setTimeout(resolve, timeout))
}
export async function scroll({
totalDistance,
stepDistance = 100,
}: {
totalDistance: number
stepDistance?: number
}) {
let distance = 0
while ((distance += stepDistance) < totalDistance) {
await page.mouse.wheel({ deltaY: stepDistance })
}
}
export function assert(condition: boolean, err?: Error | string): asserts condition {
if (!condition) throw typeof err === 'string' ? new Error(err) : err
}
let counter = 0
export async function listenTo(
event: string,
target: 'document' | 'window',
callback: <Args extends unknown[]>(...args: Args) => void,
oneTime?: boolean,
) {
const callbackName = 'onEvent' + ++counter
await page.exposeFunction(callbackName, callback)
await page.evaluate(
(event: string, target: 'window' | 'document', callbackName: string, oneTime?: boolean) => {
const t = target === 'document' ? document : window
const onEvent = (...args: unknown[]): void => {
const method = window[callbackName as keyof Window]
method?.(...args)
if (oneTime) t.removeEventListener(event, onEvent)
}
t.addEventListener(event, onEvent)
},
event,
target,
callbackName,
oneTime || false,
)
}
export function once(event: string, target: 'document' | 'window') {
return new Promise(resolve => {
listenTo(
event,
target,
(...args) => {
resolve(args)
},
true,
)
})
}
export async function waitForLegacyPJAXRedirect(action?: () => void | Promise<void>) {
const promise = once('pjax:end', 'document')
await action?.()
return promise
}
export async function waitForTurboRedirect(action?: () => void | Promise<void>) {
const promise = once('turbo:load', 'document')
await action?.()
return promise
}
export async function waitForRedirect(action?: () => void | Promise<void>) {
let fired = false
const $action =
action &&
(() => {
if (fired) return
fired = true
return action()
})
return Promise.race([
waitForLegacyPJAXRedirect($action),
waitForTurboRedirect($action),
sleep(3 * 1000),
])
}
export async function patientClick(selector: string) {
await page.waitForSelector(selector)
await page.click(selector)
}
export async function expandFloatModeSidebar() {
const rect = await (
await page.$('.gitako-toggle-show-button')
)?.evaluate(button => {
const { x, y, width, height } = button.getBoundingClientRect()
// pass required properties to avoid serialization issues
return { x, y, width, height }
})
if (rect) {
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await sleep(500)
}
}
export async function collapseFloatModeSidebar() {
await page.mouse.move(600, 600, {
steps: 100,
})
await sleep(500)
}
export function getTextContent(query: string) {
return page.evaluate(query => document.querySelector(query)?.textContent, query)
}

30
e2e/baseline.spec.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* Confirm basic behaviors of playwright assertions
*/
import { expect, test } from './fixtures'
import { testURL } from './testURL'
test.describe('in random page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://google.com`)
})
test('wait for hidden non-exist element should resolve null', async ({ extensionPage }) => {
await expect(extensionPage.locator('.non-exist-element')).toHaveCount(0)
})
test('wait for exist element', async ({ extensionPage }) => {
await expect(extensionPage.locator('*').first()).toBeVisible()
})
test('wait for non-exist element should not be visible', async ({ extensionPage }) => {
await expect(extensionPage.locator('.non-exist-element')).not.toBeVisible()
})
test('wait for non-exist element should timeout', async ({ extensionPage }) => {
await expect(async () => {
await extensionPage.waitForSelector('.non-exist-element', { timeout: 1000 })
}).rejects.toThrow()
})
})

17
e2e/empty-project.spec.ts Normal file
View file

@ -0,0 +1,17 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { getTextContent, sleep } from './utils'
test.describe('in Gitako empty project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/GitakoExtension/test-empty`)
})
test('should render error message', async ({ extensionPage }) => {
await sleep(5000)
const textContent = await getTextContent(extensionPage, selectors.gitako.errorMessage)
expect(textContent).toBe('This project seems to be empty.')
})
})

View file

@ -0,0 +1,27 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('expand to target on load and after redirect', async ({ extensionPage }) => {
await sleep(3000)
// Expect Gitako sidebar to have expanded src to see contents
await expect(extensionPage.locator(selectors.gitako.fileItemOf('src/components'))).toBeVisible({
timeout: 5000,
})
await extensionPage.click(selectors.github.fileListItemLinkOf('components'))
await waitForRedirect(extensionPage)
// Expect Gitako sidebar to have expanded components and see contents
await expect(
extensionPage.locator(selectors.gitako.fileItemOf('src/components/Gitako.tsx')),
).toBeVisible({ timeout: 5000 })
})
})

29
e2e/fixtures.ts Normal file
View file

@ -0,0 +1,29 @@
import { test as base, chromium, type BrowserContext, type Page } from '@playwright/test'
import path from 'path'
const EXTENSION_PATH = path.resolve(__dirname, '..', 'dist')
export const test = base.extend<{
context: BrowserContext
extensionPage: Page
}>({
// eslint-disable-next-line no-empty-pattern
context: async ({}, use) => {
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--no-sandbox`,
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
})
await use(context)
await context.close()
},
extensionPage: async ({ context }, use) => {
const page = await context.newPage()
await use(page)
},
})
export const expect = test.expect

View file

@ -0,0 +1,14 @@
import { expect, test } from './fixtures'
import { testURL } from './testURL'
test.describe('in GitHub homepage', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com`)
})
test('should not render Gitako', async ({ extensionPage }) => {
await expect(
extensionPage.locator('.gitako-side-bar .gitako-side-bar-body-wrapper'),
).not.toBeVisible({ timeout: 2000 })
})
})

View file

@ -0,0 +1,31 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako commits page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/commits/develop`)
})
test('should not break go back in history', async ({ extensionPage }) => {
for (let i = 0; i < 3; i++) {
const commitLinks = await extensionPage.locator(selectors.github.commitLinks).all()
if (commitLinks.length < 2) throw new Error(`No enough commits`)
await commitLinks[i].click()
await waitForRedirect(extensionPage)
await expect(extensionPage.locator(selectors.github.commitPage).first()).toBeVisible({
timeout: 5000,
})
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
// The selector for commit page should not be visible
await expect(extensionPage.locator(selectors.github.commitPage)).not.toBeVisible({
timeout: 2000,
})
}
})
})

View file

@ -0,0 +1,32 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('should not break go back in history', async ({ extensionPage }) => {
for (let i = 0; i < 3; i++) {
const fileItems = await extensionPage.locator(selectors.github.fileListItemFileLinks).all()
if (fileItems.length < 2) throw new Error(`No enough files`)
await waitForRedirect(extensionPage, async () => {
await fileItems[i].click()
})
await expect(extensionPage.locator(selectors.github.fileContent)).toBeVisible({
timeout: 5000,
})
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
// The selector for file content should not be visible
await expect(extensionPage.locator(selectors.github.fileContent)).not.toBeVisible({
timeout: 2000,
})
}
})
})

41
e2e/pjax.general.spec.ts Normal file
View file

@ -0,0 +1,41 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import {
collapseFloatModeSidebar,
expandFloatModeSidebar,
getTextContent,
patientClick,
sleep,
waitForRedirect,
} from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/develop/src`)
})
test('should work with PJAX', async ({ extensionPage }) => {
await sleep(3000)
await expandFloatModeSidebar(extensionPage)
await patientClick(extensionPage, selectors.gitako.fileItemOf('src/analytics.ts'))
await waitForRedirect(extensionPage)
await collapseFloatModeSidebar(extensionPage)
await extensionPage.click(selectors.github.navBarItemIssues)
await waitForRedirect(extensionPage)
await extensionPage.click(selectors.github.navBarItemPulls)
await waitForRedirect(extensionPage)
await extensionPage.goBack()
await sleep(1000)
await extensionPage.goBack()
await sleep(1000)
const textContent = await getTextContent(extensionPage, selectors.github.breadcrumbFileName)
expect(textContent).toBe('/analytics.ts')
})
})

30
e2e/pjax.internal.spec.ts Normal file
View file

@ -0,0 +1,30 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { expandFloatModeSidebar, patientClick, sleep, waitForRedirect } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/tree/test/multiple-changes`)
})
test('should work with PJAX', async ({ extensionPage }) => {
await sleep(3000)
await expandFloatModeSidebar(extensionPage)
await patientClick(extensionPage, selectors.gitako.fileItemOf('.babelrc'))
await waitForRedirect(extensionPage)
await expect(extensionPage.locator(selectors.github.fileContent)).toBeVisible({ timeout: 5000 })
await waitForRedirect(extensionPage, async () => {
await sleep(1000) // This prevents failing in some cases due to some mystery scheduling issue
await extensionPage.goBack()
})
// The selector for file content should not be visible
await expect(extensionPage.locator(selectors.github.fileContent)).not.toBeVisible({
timeout: 2000,
})
})
})

View file

@ -0,0 +1,46 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
import { expandFloatModeSidebar, scroll } from './utils'
test.describe('in Gitako project page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(
testURL`https://github.com/EnixCoda/Gitako/tree/test/200-changed-files-200-lines-each`,
)
})
test('should render Gitako', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.bodyWrapper)).toBeVisible({ timeout: 5000 })
})
test('should render file list', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.fileItem).first()).toBeVisible({
timeout: 5000,
})
})
test('should render while scroll', async ({ extensionPage }) => {
await expandFloatModeSidebar(extensionPage)
await extensionPage.waitForSelector(selectors.gitako.files)
// node of tsconfig.json should NOT be rendered before scroll down
await expect(
extensionPage.locator(selectors.gitako.fileItemOf('tsconfig.json')),
).not.toBeVisible({
timeout: 2000,
})
const filesEle = extensionPage.locator(selectors.gitako.files)
const box = await filesEle.boundingBox()
if (box) {
await extensionPage.mouse.move(box.x + 40, box.y + 40)
await scroll(extensionPage, { totalDistance: 10000, stepDistance: 100 })
// node of tsconfig.json should be rendered now
await expect(extensionPage.locator(selectors.gitako.fileItemOf('tsconfig.json'))).toBeVisible(
{ timeout: 5000 },
)
}
})
})

View file

@ -0,0 +1,19 @@
import { expect, test } from './fixtures'
import { selectors } from './selectors'
import { testURL } from './testURL'
test.describe('in Gitako pull request page', () => {
test.beforeEach(async ({ extensionPage }) => {
await extensionPage.goto(testURL`https://github.com/EnixCoda/Gitako/pull/71`)
})
test('should render Gitako', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.bodyWrapper)).toBeVisible({ timeout: 5000 })
})
test('should render file list', async ({ extensionPage }) => {
await expect(extensionPage.locator(selectors.gitako.fileItem).first()).toBeVisible({
timeout: 5000,
})
})
})

15
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"module": "ESNext",
"target": "ESNext",
"strict": true,
"lib": ["dom", "es2017.object", "es2016", "ES2019.Array", "ES2020.String", "ES2022.Error"],
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["./**/*.ts"]
}

98
e2e/utils.ts Normal file
View file

@ -0,0 +1,98 @@
import type { Page } from '@playwright/test'
export function sleep(timeout: number) {
return new Promise(resolve => setTimeout(resolve, timeout))
}
export async function scroll(
page: Page,
{
totalDistance,
stepDistance = 100,
}: {
totalDistance: number
stepDistance?: number
},
) {
let distance = 0
while ((distance += stepDistance) < totalDistance) {
await page.mouse.wheel(0, stepDistance)
}
}
export function assert(condition: boolean, err?: Error | string): asserts condition {
if (!condition) throw typeof err === 'string' ? new Error(err) : err
}
export async function waitForLegacyPJAXRedirect(page: Page, action?: () => void | Promise<void>) {
const promise = page.evaluate(() => {
return new Promise<void>(resolve => {
const onEvent = () => {
document.removeEventListener('pjax:end', onEvent)
resolve()
}
document.addEventListener('pjax:end', onEvent)
})
})
await action?.()
return promise
}
export async function waitForTurboRedirect(page: Page, action?: () => void | Promise<void>) {
const promise = page.evaluate(() => {
return new Promise<void>(resolve => {
const onEvent = () => {
document.removeEventListener('turbo:load', onEvent)
resolve()
}
document.addEventListener('turbo:load', onEvent)
})
})
await action?.()
return promise
}
export async function waitForRedirect(page: Page, action?: () => void | Promise<void>) {
let fired = false
const $action =
action &&
(() => {
if (fired) return
fired = true
return action()
})
return Promise.race([
waitForLegacyPJAXRedirect(page, $action),
waitForTurboRedirect(page, $action),
sleep(3 * 1000),
])
}
export async function patientClick(page: Page, selector: string) {
await page.waitForSelector(selector)
await page.click(selector)
}
export async function expandFloatModeSidebar(page: Page) {
const button = page.locator('.gitako-toggle-show-button')
const rect = await button.boundingBox()
if (rect) {
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2)
await sleep(500)
}
}
export async function collapseFloatModeSidebar(page: Page) {
await page.mouse.move(600, 600, {
steps: 100,
})
await sleep(500)
}
export async function getTextContent(
page: Page,
query: string,
): Promise<string | null | undefined> {
return page.evaluate((query: string) => document.querySelector(query)?.textContent, query)
}

View file

@ -1,21 +0,0 @@
const path = require('path')
if (process.arch === 'arm64' && process.platform === 'darwin') {
require('dotenv').config()
}
const CRX_PATH = path.resolve(__dirname, 'dist')
module.exports = {
launch: {
// set by mujo-code/puppeteer-headful on GitHub actions
// also for usages on ARM chip Mac
executablePath: process.env.PUPPETEER_EXEC_PATH,
// required for enabling extensions
headless: false,
args: [
`--no-sandbox`,
`--disable-extensions-except=${CRX_PATH}`,
`--load-extension=${CRX_PATH}`,
],
},
}

View file

@ -20,7 +20,8 @@
"build": "VERSION=v$(node scripts/get-version.js) NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack", "build": "VERSION=v$(node scripts/get-version.js) NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack",
"build:all": "GITAKO_TARGET= yarn run build", "build:all": "GITAKO_TARGET= yarn run build",
"build:analyze": "ANALYZE= yarn run build", "build:analyze": "ANALYZE= yarn run build",
"test": "NODE_ENV=test jest --config __tests__/jest.puppeteer.config.js", "test": "playwright test",
"test:ui": "playwright test --ui",
"test:unit": "NODE_ENV=test jest --config jest.config.js" "test:unit": "NODE_ENV=test jest --config jest.config.js"
}, },
"dependencies": { "dependencies": {
@ -51,8 +52,10 @@
"@babel/preset-env": "^7.16.11", "@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7", "@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7", "@babel/preset-typescript": "^7.16.7",
"@playwright/test": "^1.40.0",
"@sentry/cli": "^1.64.2", "@sentry/cli": "^1.64.2",
"@testing-library/react": "^13.3.0", "@testing-library/react": "^13.3.0",
"@types/dotenv": "^6",
"@types/firefox-webext-browser": "^120.0.3", "@types/firefox-webext-browser": "^120.0.3",
"@types/history": "^5.0.0", "@types/history": "^5.0.0",
"@types/ini": "^1.3.31", "@types/ini": "^1.3.31",
@ -60,7 +63,6 @@
"@types/js-base64": "^3.3.1", "@types/js-base64": "^3.3.1",
"@types/node": "^18", "@types/node": "^18",
"@types/nprogress": "^0.0.29", "@types/nprogress": "^0.0.29",
"@types/puppeteer": "^5.4.3",
"@types/react": "^18.0.9", "@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3", "@types/react-dom": "^18.0.3",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
@ -83,13 +85,11 @@
"husky": "^8.0.1", "husky": "^8.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-puppeteer": "^10.0.1",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prettier": "^2.8.3", "prettier": "^2.8.3",
"puppeteer": "^22.12.1",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.0",
"sass": "^1.26.2", "sass": "^1.26.2",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",

45
playwright.config.mts Normal file
View file

@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test'
import * as dotenv from 'dotenv'
import * as path from 'path'
import { fileURLToPath } from 'url'
if (process.arch === 'arm64' && process.platform === 'darwin') {
dotenv.config()
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const EXTENSION_PATH = path.resolve(__dirname, 'dist')
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: 'html',
timeout: 60000,
expect: {
timeout: 10000,
},
use: {
trace: 'on-first-retry',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Load the extension
launchOptions: {
args: [
`--no-sandbox`,
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
headless: false,
},
},
},
],
})

View file

@ -1,6 +1,6 @@
const path = require('path') const path = require('path')
const { promises: fs, existsSync } = require('fs') const { promises: fs, existsSync } = require('fs')
const puppeteer = require('puppeteer') const { chromium } = require('@playwright/test')
const generateFileIconIndex = require('./generate-file-icon-index') const generateFileIconIndex = require('./generate-file-icon-index')
const generateFolderIconIndex = require('./generate-folder-icon-index') const generateFolderIconIndex = require('./generate-folder-icon-index')
const { emitDirPath, checkEmitDir } = require('./check-emit-dir') const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
@ -8,8 +8,9 @@ const { emitDirPath, checkEmitDir } = require('./check-emit-dir')
let browser let browser
async function getPage() { async function getPage() {
const headless = process.env.HEADLESS !== 'false' const headless = process.env.HEADLESS !== 'false'
browser = browser || (await puppeteer.launch({ headless })) browser = browser || (await chromium.launch({ headless }))
return await browser.newPage() const context = await browser.newContext()
return await context.newPage()
} }
async function generateCSV() { async function generateCSV() {

700
yarn.lock

File diff suppressed because it is too large Load diff