Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref != 'refs/heads/master') ||
(github.event_name == 'pull_request' && github.base_ref != 'master') ||
(github.event_name == 'workflow_dispatch' && inputs.run_full != true)

steps:
Expand All @@ -56,8 +55,8 @@ jobs:
name: Full lane (coverage + full e2e)
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
(github.event_name == 'pull_request' && github.base_ref == 'master') ||
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' &&
inputs.run_full == true)
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- CI full lane (`test:ci:full`) runs on every pull request; fast lane
remains on feature-branch pushes only; Vitest `pool: forks` and shared
Matrix client spec mocks stabilize coverage runs
([#122](https://github.com/mjkatgithub/Decentra/issues/122))

### Fixed

- Synapse E2E on Linux CI: restore data-dir ownership after `synapse generate`
so `homeserver.yaml` overrides are writable (GitHub Actions EACCES)
([#122](https://github.com/mjkatgithub/Decentra/issues/122))
- E2E runner: `start-server-and-test` third argument must be an npm script
name (fixes local + CI `test:e2e:run` / smoke)
([#122](https://github.com/mjkatgithub/Decentra/issues/122))
- Synapse E2E data dir: restore UID 991 after patching config so the
container can read `localhost.signing.key`; merge all E2E env files;
run Cucumber via Node (fixes Windows `@tag` ENOENT)
([#122](https://github.com/mjkatgithub/Decentra/issues/122))

- Leave Matrix rooms from the chat sidebar: confirmation dialog,
`leaveRoom` via matrix-js-sdk, `m.direct` cleanup for DMs, neutral
chat view after leaving the active room; Cucumber E2E for ephemeral
Expand Down
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ npm run test:ci:full
npm run prepare:e2e
```

### Test layers (current state)

| Layer | Location | Role today |
|-------|----------|------------|
| Unit | `tests/unit/` | Main safety net (~438 tests); composables, utils, components |
| Integration | `tests/integration/` | Small module-level checks (3 files, 9 tests); not full Nuxt/Matrix wiring |
| E2E | `tests/e2e/` | Cucumber + Playwright against Docker Synapse |

**Coverage:** `npm run test:coverage` reports **~50% lines** overall
(`vitest.config.ts` thresholds are intentionally low at 10–20% so CI
passes while pages/plugins stay mostly untested). A **~90%** target is
reasonable for critical modules (Matrix client, timeline, auth) but is **not**
the current baseline — raise thresholds incrementally per epic/issue.

**Integration gap:** Issue templates mention integration tests; most features
today rely on unit + E2E. Expanding `tests/integration/` (e.g. composable
chains, i18n + component mount, timeline with mocked Matrix room) is backlog
work, not part of the CI lane fix.

### E2E Credentials

For credential-based E2E scenarios, create a local env file:
Expand All @@ -104,18 +123,29 @@ The local file stays untracked.

This repo uses `.github/workflows/ci.yml` with two lanes:

- **Fast lane:** runs on push + pull request (`test:ci:fast`).
- **Full lane:** runs nightly and optionally manual
(`test:ci:full`).
- **Fast lane:** runs on push to non-`master` branches (feature branches,
`develop`); runs `test:ci:fast` (unit + integration + E2E smoke).
- **Full lane:** runs on every pull request (targets `develop` or
`master`), push to `master`, nightly schedule, and manual dispatch with
`run_full: true`; runs `test:ci:full` (integration + coverage + full
Synapse E2E via Docker).

### E2E credentials (full lane)

### Required repository secrets (for full lane)
The full lane starts a local Synapse stack in Docker and writes
`tests/e2e/.env.e2e.generated` during seeding — **no GitHub secrets are
required** for the default CI full-lane path.

Set these in GitHub under **Settings > Secrets and variables > Actions**:
For optional scenarios against an external homeserver (e.g. matrix.org),
set repository secrets under **Settings > Secrets and variables > Actions**:

- `E2E_MATRIX_HOMESERVER` (optional, defaults to `https://matrix.org`)
- `E2E_MATRIX_USERNAME`
- `E2E_MATRIX_PASSWORD`

For local credential-based runs, copy `tests/e2e/.env.e2e.example` to
`tests/e2e/.env.e2e.local` and fill in username/password (see above).

### How to run manually

1. Open your repository on GitHub.
Expand Down
14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration --config vitest.integration.config.ts",
"test:e2e": "node tests/e2e/scripts/run-synapse-e2e.mjs",
"test:e2e:run": "nuxt build && start-server-and-test preview 3000 node tests/e2e/scripts/run-cucumber-exclude-email.mjs",
"test:e2e:run:email": "nuxt build && start-server-and-test preview 3000 node tests/e2e/scripts/run-cucumber-email-only.mjs",
"test:e2e:run:recaptcha": "nuxt build && start-server-and-test preview 3000 node tests/e2e/scripts/run-cucumber-recaptcha-only.mjs",
"test:e2e:smoke": "nuxt build && start-server-and-test preview 3000 \"cucumber-js --tags @smoke\"",
"test:e2e:cucumber": "node tests/e2e/scripts/run-cucumber-exclude-email.mjs",
"test:e2e:cucumber:email": "node tests/e2e/scripts/run-cucumber-email-only.mjs",
"test:e2e:cucumber:recaptcha": "node tests/e2e/scripts/run-cucumber-recaptcha-only.mjs",
"test:e2e:smoke:cucumber": "node tests/e2e/scripts/run-cucumber-smoke.mjs",
"test:e2e:run": "nuxt build && start-server-and-test preview 3000 test:e2e:cucumber",
"test:e2e:run:email": "nuxt build && start-server-and-test preview 3000 test:e2e:cucumber:email",
"test:e2e:run:recaptcha": "nuxt build && start-server-and-test preview 3000 test:e2e:cucumber:recaptcha",
"test:e2e:smoke": "nuxt build && start-server-and-test preview 3000 test:e2e:smoke:cucumber",
"test:e2e:signup-email": "cross-env DECENTRA_E2E_SIGNUP_EMAIL=1 node tests/e2e/scripts/run-synapse-e2e-signup-email.mjs",
"test:e2e:signup-recaptcha": "cross-env DECENTRA_E2E_SIGNUP_RECAPTCHA=1 node tests/e2e/scripts/run-synapse-e2e-signup-recaptcha.mjs",
"test:coverage": "vitest run --coverage",
"test:ci:fast": "npm run test:unit && npm run test:integration && npm run test:e2e:smoke",
"test:ci:full": "npm run test:unit && npm run test:integration && npm run test:coverage && npm run test:e2e",
"test:ci:full": "npm run test:integration && npm run test:coverage && npm run test:e2e",
"test:e2e:headed": "cross-env HEADLESS=false npm run test:e2e",
"prepare:e2e": "playwright install chromium",
"check:line-limit": "node scripts/check-file-line-limit.mjs"
Expand Down
54 changes: 54 additions & 0 deletions tests/e2e/scripts/run-cucumber-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadE2EEnv } from './runtime-e2e-env.mjs'

const currentFilePath = fileURLToPath(import.meta.url)
export const workspaceRoot = resolve(
dirname(currentFilePath),
'..',
'..',
'..'
)

function resolveCucumberEntry() {
const entry = resolve(
workspaceRoot,
'node_modules/@cucumber/cucumber/bin/cucumber.js'
)
if (!existsSync(entry)) {
throw new Error(`Missing @cucumber/cucumber at ${entry}`)
}
return entry
}

/**
* Run Cucumber via Node (no shell) so Windows does not treat `@tag` as paths.
*
* @param {string | undefined} tagExpression
*/
export function runCucumber(tagExpression) {
loadE2EEnv(workspaceRoot)

const args = [resolveCucumberEntry()]
if (tagExpression) {
args.push('--tags', tagExpression)
}

return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(process.execPath, args, {
cwd: workspaceRoot,
stdio: 'inherit',
env: process.env,
})
child.on('error', (error) => rejectPromise(error))
child.on('close', (exitCode) => {
if (exitCode === 0) {
resolvePromise()
return
}
rejectPromise(new Error(`cucumber-js exited with code ${exitCode ?? 1}`))
})
})
}
35 changes: 2 additions & 33 deletions tests/e2e/scripts/run-cucumber-email-only.mjs
Original file line number Diff line number Diff line change
@@ -1,37 +1,6 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { runCucumber } from './run-cucumber-cli.mjs'

const workspaceRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..'
)

function run() {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(
'npx',
['cucumber-js', '--tags', '@email_signup'],
{
cwd: workspaceRoot,
stdio: 'inherit',
shell: process.platform === 'win32'
}
)
child.on('error', (error) => rejectPromise(error))
child.on('close', (code) => {
if (code === 0) {
resolvePromise()
return
}
rejectPromise(new Error(`cucumber-js exited with code ${code ?? 1}`))
})
})
}

void run().catch((error) => {
void runCucumber('@email_signup').catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
35 changes: 2 additions & 33 deletions tests/e2e/scripts/run-cucumber-exclude-email.mjs
Original file line number Diff line number Diff line change
@@ -1,37 +1,6 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { runCucumber } from './run-cucumber-cli.mjs'

const workspaceRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..'
)

function run() {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(
'npx',
['cucumber-js', '--tags', 'not @email_signup and not @recaptcha_signup'],
{
cwd: workspaceRoot,
stdio: 'inherit',
shell: process.platform === 'win32'
}
)
child.on('error', (error) => rejectPromise(error))
child.on('close', (code) => {
if (code === 0) {
resolvePromise()
return
}
rejectPromise(new Error(`cucumber-js exited with code ${code ?? 1}`))
})
})
}

void run().catch((error) => {
void runCucumber('not @email_signup and not @recaptcha_signup').catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
35 changes: 2 additions & 33 deletions tests/e2e/scripts/run-cucumber-recaptcha-only.mjs
Original file line number Diff line number Diff line change
@@ -1,37 +1,6 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { runCucumber } from './run-cucumber-cli.mjs'

const workspaceRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..'
)

function run() {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(
'npx',
['cucumber-js', '--tags', '@recaptcha_signup'],
{
cwd: workspaceRoot,
stdio: 'inherit',
shell: process.platform === 'win32'
}
)
child.on('error', (error) => rejectPromise(error))
child.on('close', (code) => {
if (code === 0) {
resolvePromise()
return
}
rejectPromise(new Error(`cucumber-js exited with code ${code ?? 1}`))
})
})
}

void run().catch((error) => {
void runCucumber('@recaptcha_signup').catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
6 changes: 6 additions & 0 deletions tests/e2e/scripts/run-cucumber-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { runCucumber } from './run-cucumber-cli.mjs'

void runCucumber('@smoke').catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
10 changes: 10 additions & 0 deletions tests/e2e/scripts/run-synapse-e2e.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadE2EEnv } from './runtime-e2e-env.mjs'

const currentFilePath = fileURLToPath(import.meta.url)
const workspaceRoot = resolve(dirname(currentFilePath), '..', '..', '..')
const generatedEnvPath = resolve(workspaceRoot, 'tests/e2e/.env.e2e.generated')

function runCommand(binary, args) {
return new Promise((resolvePromise, rejectPromise) => {
Expand All @@ -27,6 +30,13 @@ async function main() {
let testFailed = false
await runCommand('node', ['tests/e2e/scripts/runtime-manage-synapse.mjs', 'up'])
await runCommand('node', ['tests/e2e/scripts/runtime-seed-synapse.mjs'])
loadE2EEnv(workspaceRoot)
if (!existsSync(generatedEnvPath) || !process.env.E2E_MATRIX_USERNAME) {
throw new Error(
'Synapse seed did not write E2E credentials to '
+ 'tests/e2e/.env.e2e.generated'
)
}

try {
await runCommand('npm', ['run', 'test:e2e:run'])
Expand Down
1 change: 0 additions & 1 deletion tests/e2e/scripts/runtime-e2e-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export function loadE2EEnv(workspaceRoot) {
const variableValue = trimmedLine.slice(separatorIndex + 1).trim()
process.env[variableName] = variableValue
}
break
}

if (parseBoolean(process.env.E2E_USE_LOCAL_SYNAPSE)) {
Expand Down
Loading
Loading