diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..e1187250 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,43 @@ +name: Integration +'on': + workflow_dispatch: {} +jobs: + integration: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-26 + - windows-2025 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + PLAYWRIGHT_BROWSERS_PATH: 0 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - run: npm ci + - run: npm run build + - uses: actions/checkout@v7 + with: + repository: lvce-editor/lvce-editor + ref: 236de223cab8f9eeb72dc68a260b63f0db3b4ef9 + path: .tmp/lvce-integration + - uses: actions/setup-node@v6 + with: + node-version-file: .tmp/lvce-integration/.nvmrc + - name: Install application dependencies + working-directory: .tmp/lvce-integration + run: npm ci + - name: Install Chromium + working-directory: .tmp/lvce-integration/packages/extension-host-worker-tests + run: npx playwright install --with-deps chromium + - name: Prepare owned integration tests + run: node packages/e2e-integration/prepare.mjs .tmp/lvce-integration + - name: Run owned integration tests + working-directory: .tmp/lvce-integration/packages/extension-host-worker-tests + shell: bash + run: npm run e2e:headless -- diff --git a/eslint.config.js b/eslint.config.js index 23989a49..c3ace182 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,10 +31,25 @@ export default defineConfig([ }, }, { - files: ['packages/e2e/**/*.ts'], + files: ['packages/{e2e,e2e-integration}/**/*.ts'], rules: { '@cspell/spellchecker': 'off', 'e2e/prefer-filesystem-set-files': 'off', }, }, + { + // The pinned application supplies its own Node runtime. + files: ['.github/workflows/integration.yml'], + rules: { 'github-actions/node-version-file': 'off', 'github-actions/on': 'off' }, + }, + { + // The application runner supplies mutable API objects to these scenarios. + files: ['packages/e2e-integration/src/**/*.ts'], + rules: { '@typescript-eslint/prefer-readonly-parameter-types': 'off' }, + }, + { + // The application runner supplies mutable API objects to these scenarios. + files: ['packages/e2e-integration/src/**/*.ts'], + rules: { '@typescript-eslint/prefer-readonly-parameter-types': 'off' }, + }, ]) diff --git a/packages/e2e-integration/README.md b/packages/e2e-integration/README.md new file mode 100644 index 00000000..599c582e --- /dev/null +++ b/packages/e2e-integration/README.md @@ -0,0 +1,15 @@ +# Application integration tests + +These scenarios and fixtures moved from `lvce-editor` to `lvce-editor/diff-view`. The Integration workflow overlays this repository's build in a pinned, disposable LVCE checkout and runs the application's existing test runner. + +The workflow preserves the original CI commands, settings, and platform restrictions. Scenarios that were outside the application's CI selection remain available for local runs; their existing skip declarations are unchanged. Repositories with no previously selected CI scenarios expose a manual Integration workflow. + +Build this repository, install the pinned application's dependencies and Chromium, then run: + +```sh +node packages/e2e-integration/prepare.mjs /path/to/disposable/lvce-editor +cd /path/to/disposable/lvce-editor/packages/extension-host-worker-tests +npm run e2e:headless -- +``` + +Preparation replaces the disposable application's scenarios and fixtures and overlays local build artifacts. See `config.json` for artifact and script destinations, and `.github/workflows/integration.yml` for static export, Electron, and settings requirements. Update the pinned application commit when its runtime needs updating. diff --git a/packages/e2e-integration/config.json b/packages/e2e-integration/config.json new file mode 100644 index 00000000..e08a5be0 --- /dev/null +++ b/packages/e2e-integration/config.json @@ -0,0 +1,4 @@ +{ + "artifacts": [[".tmp/dist", "packages/renderer-worker/node_modules/@lvce-editor/diff-view"]], + "scripts": [] +} diff --git a/packages/e2e-integration/prepare.mjs b/packages/e2e-integration/prepare.mjs new file mode 100644 index 00000000..a522aa35 --- /dev/null +++ b/packages/e2e-integration/prepare.mjs @@ -0,0 +1,35 @@ +import { cp, mkdir, readdir, readFile, realpath, rm } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const owner = resolve(here, '../..') +if (!process.argv[2]) throw new Error('Pass the path to a disposable LVCE checkout') +const application = resolve(process.argv[2]) +const manifest = JSON.parse(await readFile(join(application, 'package.json'), 'utf8')) +if (manifest.name !== 'lvce-editor') throw new Error('Expected an LVCE application checkout') +const config = JSON.parse(await readFile(join(here, 'config.json'), 'utf8')) +const tests = join(application, 'packages/extension-host-worker-tests') +// Keep the application's runner and replace only its test inventory. +for (const name of await readdir(join(tests, 'src'))) { + if (name !== '_all.js') await rm(join(tests, 'src', name), { recursive: true }) +} +await cp(join(here, 'src'), join(tests, 'src'), { recursive: true }) +await rm(join(tests, 'fixtures'), { recursive: true, force: true }) +await mkdir(join(tests, 'fixtures'), { recursive: true }) +try { + await cp(join(here, 'fixtures'), join(tests, 'fixtures'), { recursive: true }) +} catch (error) { + if (error.code !== 'ENOENT') throw error +} +for (const path of config.scripts) { + await cp(join(here, 'scripts', path.split('/').at(-1)), join(application, path)) +} +// Exercise this repository's build in the pinned application runtime. +for (const [from, to] of config.artifacts) { + const target = await realpath(join(application, to)).catch((error) => { + if (error.code !== 'ENOENT') throw error + return join(application, to) + }) + await cp(join(owner, from), target, { recursive: true }) +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-deletion.ts b/packages/e2e-integration/src/viewlet.diff-editor-deletion.ts new file mode 100644 index 00000000..8d25beb0 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-deletion.ts @@ -0,0 +1,20 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'sample.diff-editor-deletion' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + // arrange + const tmpDir = await FileSystem.getTmpDir() + await FileSystem.writeFile(`${tmpDir}/file-1.txt`, `abc`) + await FileSystem.writeFile(`${tmpDir}/file-2.txt`, ``) + await Workspace.setUri(tmpDir) + + // act + await Main.openUri(`diff://${tmpDir}/file-1.txt<->${tmpDir}/file-2.txt`) + + // assert + const contentLeft = Locator('.DiffEditorContentLeft .DiffEditorRows') + const contentRight = Locator('.DiffEditorContentRight .DiffEditorRows') + await expect(contentLeft).toHaveText('abc') + await expect(contentRight).toHaveText('') +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-inline.ts b/packages/e2e-integration/src/viewlet.diff-editor-inline.ts new file mode 100644 index 00000000..6ba7aab6 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-inline.ts @@ -0,0 +1,16 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'viewlet.diff-editor-inline' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + const tmpDir = await FileSystem.getTmpDir() + const filePath = `${tmpDir}/file.ts` + await FileSystem.writeFile(filePath, 'const value = 2') + await Workspace.setUri(tmpDir) + + await Main.openUri(`inline-diff://data://const value = 1<->${filePath}`) + + const diffEditor = Locator('.Viewlet.DiffEditor') + await expect(diffEditor).toBeVisible() + await expect(Locator('.MainTab')).toHaveText('file.ts (Working Tree)') +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-insertion-at-start-and-end.ts b/packages/e2e-integration/src/viewlet.diff-editor-insertion-at-start-and-end.ts new file mode 100644 index 00000000..b4735b09 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-insertion-at-start-and-end.ts @@ -0,0 +1,26 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'sample.diff-editor-insertion-at-start-and-end' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + // arrange + const tmpDir = await FileSystem.getTmpDir() + await FileSystem.writeFile(`${tmpDir}/file-1.txt`, `c`) + await FileSystem.writeFile( + `${tmpDir}/file-2.txt`, + `a +b +c +d`, + ) + await Workspace.setUri(tmpDir) + + // act + await Main.openUri(`diff://${tmpDir}/file-1.txt<->${tmpDir}/file-2.txt`) + + // assert + const contentLeft = Locator('.DiffEditorContentLeft .DiffEditorRows') + const contentRight = Locator('.DiffEditorContentRight .DiffEditorRows') + await expect(contentLeft).toHaveText('c') + await expect(contentRight).toHaveText('abcd') +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-insertion.ts b/packages/e2e-integration/src/viewlet.diff-editor-insertion.ts new file mode 100644 index 00000000..c9898392 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-insertion.ts @@ -0,0 +1,24 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'sample.diff-editor-insertion' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + // arrange + const tmpDir = await FileSystem.getTmpDir() + await FileSystem.writeFile(`${tmpDir}/file-1.txt`, ``) + await FileSystem.writeFile(`${tmpDir}/file-2.txt`, `def`) + await Workspace.setUri(tmpDir) + + // act + await Main.openUri(`diff://${tmpDir}/file-1.txt<->${tmpDir}/file-2.txt`) + + // assert + const contentLeft = Locator('.DiffEditorContentLeft .DiffEditorRows') + const contentRight = Locator('.DiffEditorContentRight .DiffEditorRows') + await expect(contentLeft).toHaveText('') + await expect(contentRight).toHaveText('def') + const rowLeft = contentLeft.locator('.EditorRow') + await expect(rowLeft).toHaveClass('Deletion') + const rowRight = contentRight.locator('.EditorRow') + await expect(rowRight).toHaveClass('Insertion') +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-scrolling.ts b/packages/e2e-integration/src/viewlet.diff-editor-scrolling.ts new file mode 100644 index 00000000..01bac798 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-scrolling.ts @@ -0,0 +1,38 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +const getContent = (bottomLine: string): string => { + const lines: string[] = [] + for (let index = 1; index <= 1800; index++) { + lines.push(`shared line ${index}`) + } + lines.push(bottomLine) + return lines.join('\n') +} + +export const name = 'viewlet.diff-editor-scrolling' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + const tmpDir = await FileSystem.getTmpDir() + await FileSystem.writeFile(`${tmpDir}/file-1.txt`, getContent('bottom change before')) + await FileSystem.writeFile(`${tmpDir}/file-2.txt`, getContent('bottom change after')) + await Workspace.setUri(tmpDir) + + await Main.openUri(`diff://${tmpDir}/file-1.txt<->${tmpDir}/file-2.txt`) + + const line1 = Locator('.DiffEditorContentLeft .DiffEditorLineNumber', { + hasText: '1', + }) + await expect(line1).toBeVisible() + + const contentRight = Locator('.DiffEditorContentRight') + await contentRight.dispatchEvent('wheel', { + bubbles: true, + deltaMode: 0, + deltaY: 9_999_999, + }) + + const line1800 = Locator('.DiffEditorContentLeft .DiffEditorLineNumber', { + hasText: '1800', + }) + await expect(line1800).toBeVisible() +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor-syntax-highlighting.ts b/packages/e2e-integration/src/viewlet.diff-editor-syntax-highlighting.ts new file mode 100644 index 00000000..bd655c42 --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor-syntax-highlighting.ts @@ -0,0 +1,17 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'viewlet.diff-editor-syntax-highlighting' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + const tmpDir = await FileSystem.getTmpDir() + const filePath = `${tmpDir}/file.ts` + await FileSystem.writeFile(filePath, 'const rightValue = 2') + await Workspace.setUri(tmpDir) + + await Main.openUri(`diff://data://const leftValue = 1<->${filePath}`) + + await expect(Locator('.DiffEditorContentLeft .Token.Keyword')).toHaveText('const') + await expect(Locator('.DiffEditorContentLeft .Token.Numeric')).toHaveText('1') + await expect(Locator('.DiffEditorContentRight .Token.Keyword')).toHaveText('const') + await expect(Locator('.DiffEditorContentRight .Token.Numeric')).toHaveText('2') +} diff --git a/packages/e2e-integration/src/viewlet.diff-editor.ts b/packages/e2e-integration/src/viewlet.diff-editor.ts new file mode 100644 index 00000000..c4063acb --- /dev/null +++ b/packages/e2e-integration/src/viewlet.diff-editor.ts @@ -0,0 +1,20 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'sample.diff-editor' + +export const test: Test = async ({ expect, FileSystem, Locator, Main, Workspace }) => { + // arrange + const tmpDir = await FileSystem.getTmpDir() + await FileSystem.writeFile(`${tmpDir}/file-1.txt`, `abc`) + await FileSystem.writeFile(`${tmpDir}/file-2.txt`, `def`) + await Workspace.setUri(tmpDir) + + // act + await Main.openUri(`diff://${tmpDir}/file-1.txt<->${tmpDir}/file-2.txt`) + + // assert + const contentLeft = Locator('.DiffEditorContentLeft .DiffEditorRows') + const contentRight = Locator('.DiffEditorContentRight .DiffEditorRows') + await expect(contentLeft).toHaveText('abc') + await expect(contentRight).toHaveText('def') +} diff --git a/packages/e2e-integration/tsconfig.json b/packages/e2e-integration/tsconfig.json new file mode 100644 index 00000000..a97c7fb0 --- /dev/null +++ b/packages/e2e-integration/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "WebWorker"], + "module": "nodenext", + "moduleResolution": "nodenext", + "skipLibCheck": false, + "noEmit": true, + "strict": true, + "noImplicitAny": true, + "allowJs": true, + "types": [], + "allowImportingTsExtensions": true, + "assumeChangesOnlyAffectDirectDependencies": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "fixtures"] +}