Files
gitea-mirror/tests/e2e/02-mirror-workflow.spec.ts
Xyndra 2e00a610cb Add E2E testing (#201)
* feat: add E2E testing infrastructure with fake GitHub, Playwright, and CI workflow

- Add fake GitHub API server (tests/e2e/fake-github-server.ts) with
  management API for seeding test data
- Add Playwright E2E test suite covering full mirror workflow:
  service health checks, user registration, config, sync, verify
- Add Docker Compose for E2E Gitea instance
- Add orchestrator script (run-e2e.sh) with cleanup
- Add GitHub Actions workflow (e2e-tests.yml) with Gitea service container
- Make GITHUB_API_URL configurable via env var for testing
- Add npm scripts: test:e2e, test:e2e:ci, test:e2e:keep, test:e2e:cleanup

* feat: add real git repos + backup config testing to E2E suite

- Create programmatic test git repos (create-test-repos.ts) with real
  commits, branches (main, develop, feature/*), and tags (v1.0.0, v1.1.0)
- Add git-server container to docker-compose serving bare repos via
  dumb HTTP protocol so Gitea can actually clone them
- Update fake GitHub server to emit reachable clone_url fields pointing
  to the git-server container (configurable via GIT_SERVER_URL env var)
- Add management endpoint POST /___mgmt/set-clone-url for runtime config
- Update E2E spec with real mirroring verification:
  * Verify repos appear in Gitea with actual content
  * Check branches, tags, commits, file content
  * Verify 4/4 repos mirrored successfully
- Add backup configuration test suite:
  * Enable/disable backupBeforeSync config
  * Toggle blockSyncOnBackupFailure
  * Trigger re-sync with backup enabled and verify activities
  * Verify config persistence across changes
- Update CI workflow to use docker compose (not service containers)
  matching the local run-e2e.sh approach
- Update cleanup.sh for git-repos directory and git-server port
- All 22 tests passing with real git content verification

* refactor: split E2E tests into focused files + add force-push tests

Split the monolithic e2e.spec.ts (1335 lines) into 5 focused spec files
and a shared helpers module:

  helpers.ts                 — constants, GiteaAPI, auth, saveConfig, utilities
  01-health.spec.ts          — service health checks (4 tests)
  02-mirror-workflow.spec.ts — full first-mirror journey (8 tests)
  03-backup.spec.ts          — backup config toggling (6 tests)
  04-force-push.spec.ts      — force-push simulation & backup verification (9 tests)
  05-sync-verification.spec.ts — dynamic repos, content integrity, reset (5 tests)

The force-push tests are the critical addition:
  F0: Record original state (commit SHAs, file content)
  F1: Rewrite source repo history (simulate force-push)
  F2: Sync to Gitea WITHOUT backup
  F3: Verify data loss — LICENSE file gone, README overwritten
  F4: Restore source, re-mirror to clean state
  F5: Enable backup, force-push again, sync through app
  F6: Verify Gitea reflects the force-push
  F7: Verify backup system was invoked (snapshot activities logged)
  F8: Restore source repo for subsequent tests

Also added to helpers.ts:
  - GiteaAPI.getBranch(), .getCommit(), .triggerMirrorSync()
  - getRepositoryIds(), triggerMirrorJobs(), triggerSyncRepo()

All 32 tests passing.

* Try to fix actions

* Try to fix the other action

* Add debug info to check why e2e action is failing

* More debug info

* Even more debug info

* E2E fix attempt #1

* E2E fix attempt #2

* more debug again

* E2E fix attempt #3

* E2E fix attempt #4

* Remove a bunch of debug info

* Hopefully fix backup bug

* Force backups to succeed
2026-03-01 07:35:13 +05:30

345 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 02 Main mirror workflow.
*
* Walks through the full first-time user journey:
* 1. Create Gitea admin user + API token
* 2. Create the mirror target organization
* 3. Register / sign-in to the gitea-mirror app
* 4. Save GitHub + Gitea configuration
* 5. Trigger a GitHub data sync (pull repo list from fake GitHub)
* 6. Trigger mirror jobs (push repos into Gitea)
* 7. Verify repos actually appeared in Gitea with real content
* 8. Verify mirror job activity and app state
*/
import { test, expect } from "@playwright/test";
import {
APP_URL,
GITEA_URL,
GITEA_MIRROR_ORG,
GiteaAPI,
getAppSessionCookies,
saveConfig,
waitFor,
getRepositoryIds,
triggerMirrorJobs,
} from "./helpers";
test.describe("E2E: Mirror workflow", () => {
let giteaApi: GiteaAPI;
let appCookies = "";
test.beforeAll(async () => {
giteaApi = new GiteaAPI(GITEA_URL);
});
test.afterAll(async () => {
await giteaApi.dispose();
});
test("Step 1: Setup Gitea admin user and token", async () => {
await giteaApi.ensureAdminUser();
const token = await giteaApi.createToken();
expect(token).toBeTruthy();
expect(token.length).toBeGreaterThan(10);
console.log(`[Setup] Gitea token acquired (length: ${token.length})`);
});
test("Step 2: Create mirror organization in Gitea", async () => {
await giteaApi.ensureOrg(GITEA_MIRROR_ORG);
const repos = await giteaApi.listOrgRepos(GITEA_MIRROR_ORG);
expect(Array.isArray(repos)).toBeTruthy();
console.log(
`[Setup] Org ${GITEA_MIRROR_ORG} exists with ${repos.length} repos`,
);
});
test("Step 3: Register and sign in to gitea-mirror app", async ({
request,
}) => {
appCookies = await getAppSessionCookies(request);
expect(appCookies).toBeTruthy();
console.log(
`[Auth] Session cookies acquired (length: ${appCookies.length})`,
);
const whoami = await request.get(`${APP_URL}/api/config`, {
headers: { Cookie: appCookies },
failOnStatusCode: false,
});
expect(
whoami.status(),
`Auth check returned ${whoami.status()} cookies may be invalid`,
).not.toBe(401);
console.log(`[Auth] Auth check status: ${whoami.status()}`);
});
test("Step 4: Configure mirrors via API (backup disabled)", async ({
request,
}) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
const giteaToken = giteaApi.getTokenValue();
expect(giteaToken, "Gitea token should be set from Step 1").toBeTruthy();
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupBeforeSync: false,
blockSyncOnBackupFailure: false,
},
});
console.log("[Config] Configuration saved (backup disabled)");
});
test("Step 5: Trigger GitHub data sync (fetch repos from fake GitHub)", async ({
request,
}) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
const syncResp = await request.post(`${APP_URL}/api/sync`, {
headers: {
"Content-Type": "application/json",
Cookie: appCookies,
},
failOnStatusCode: false,
});
const status = syncResp.status();
console.log(`[Sync] GitHub sync response: ${status}`);
if (status >= 400) {
const body = await syncResp.text();
console.log(`[Sync] Error body: ${body}`);
}
expect(status, "Sync should not be unauthorized").not.toBe(401);
expect(status, "Sync should not return server error").toBeLessThan(500);
if (syncResp.ok()) {
const data = await syncResp.json();
console.log(
`[Sync] New repos: ${data.newRepositories ?? "?"}, new orgs: ${data.newOrganizations ?? "?"}`,
);
}
});
test("Step 6: Trigger mirror jobs (push repos to Gitea)", async ({
request,
}) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
// Fetch repository IDs from the dashboard API
const { ids: repositoryIds, repos } = await getRepositoryIds(
request,
appCookies,
);
console.log(
`[Mirror] Found ${repositoryIds.length} repos to mirror: ${repos.map((r: any) => r.name).join(", ")}`,
);
if (repositoryIds.length === 0) {
// Fallback: try the github/repositories endpoint
const repoResp = await request.get(
`${APP_URL}/api/github/repositories`,
{
headers: { Cookie: appCookies },
failOnStatusCode: false,
},
);
if (repoResp.ok()) {
const repoData = await repoResp.json();
const fallbackRepos: any[] = Array.isArray(repoData)
? repoData
: (repoData.repositories ?? []);
repositoryIds.push(...fallbackRepos.map((r: any) => r.id));
console.log(
`[Mirror] Fallback: found ${repositoryIds.length} repos`,
);
}
}
expect(
repositoryIds.length,
"Should have at least one repository to mirror",
).toBeGreaterThan(0);
const status = await triggerMirrorJobs(
request,
appCookies,
repositoryIds,
30_000,
);
console.log(`[Mirror] Mirror job response: ${status}`);
expect(status, "Mirror job should not be unauthorized").not.toBe(401);
expect(status, "Mirror job should not return server error").toBeLessThan(
500,
);
});
test("Step 7: Verify repos were actually mirrored to Gitea", async ({
request,
}) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
// Wait for mirror jobs to finish processing
await waitFor(
async () => {
const orgRepos = await giteaApi.listOrgRepos(GITEA_MIRROR_ORG);
console.log(
`[Verify] Gitea org repos so far: ${orgRepos.length} (${orgRepos.map((r: any) => r.name).join(", ")})`,
);
// We expect at least 3 repos (my-project, dotfiles, notes)
return orgRepos.length >= 3;
},
{
timeout: 90_000,
interval: 5_000,
label: "repos appear in Gitea",
},
);
const orgRepos = await giteaApi.listOrgRepos(GITEA_MIRROR_ORG);
const orgRepoNames = orgRepos.map((r: any) => r.name);
console.log(
`[Verify] Gitea org repos: ${orgRepoNames.join(", ")} (total: ${orgRepos.length})`,
);
// Check that at least the 3 personal repos are mirrored
for (const repoName of ["my-project", "dotfiles", "notes"]) {
expect(
orgRepoNames,
`Expected repo "${repoName}" to be mirrored into org ${GITEA_MIRROR_ORG}`,
).toContain(repoName);
}
// Verify my-project has actual content (branches, commits)
const myProjectBranches = await giteaApi.listBranches(
GITEA_MIRROR_ORG,
"my-project",
);
const branchNames = myProjectBranches.map((b: any) => b.name);
console.log(`[Verify] my-project branches: ${branchNames.join(", ")}`);
expect(branchNames, "main branch should exist").toContain("main");
// Verify we can read actual file content
const readmeContent = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"README.md",
);
expect(readmeContent, "README.md should have content").toBeTruthy();
expect(readmeContent).toContain("My Project");
console.log(
`[Verify] my-project README.md starts with: ${readmeContent?.substring(0, 50)}...`,
);
// Verify tags were mirrored
const tags = await giteaApi.listTags(GITEA_MIRROR_ORG, "my-project");
const tagNames = tags.map((t: any) => t.name);
console.log(`[Verify] my-project tags: ${tagNames.join(", ")}`);
if (tagNames.length > 0) {
expect(tagNames).toContain("v1.0.0");
}
// Verify commits exist
const commits = await giteaApi.listCommits(
GITEA_MIRROR_ORG,
"my-project",
);
console.log(`[Verify] my-project commits: ${commits.length}`);
expect(commits.length, "Should have multiple commits").toBeGreaterThan(0);
// Verify dotfiles repo has content
const bashrc = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"dotfiles",
".bashrc",
);
expect(bashrc, "dotfiles should contain .bashrc").toBeTruthy();
console.log("[Verify] dotfiles .bashrc verified");
});
test("Step 8: Verify mirror jobs and app state", async ({ request }) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
// Check activity log
const activitiesResp = await request.get(`${APP_URL}/api/activities`, {
headers: { Cookie: appCookies },
failOnStatusCode: false,
});
if (activitiesResp.ok()) {
const activities = await activitiesResp.json();
const jobs: any[] = Array.isArray(activities)
? activities
: (activities.jobs ?? activities.activities ?? []);
console.log(`[State] Activity/job records: ${jobs.length}`);
const mirrorJobs = jobs.filter(
(j: any) =>
j.status === "mirroring" ||
j.status === "failed" ||
j.status === "success" ||
j.status === "mirrored" ||
j.message?.includes("mirror") ||
j.message?.includes("Mirror"),
);
console.log(`[State] Mirror-related jobs: ${mirrorJobs.length}`);
for (const j of mirrorJobs.slice(0, 5)) {
console.log(
`[State] • ${j.repositoryName ?? "?"}: ${j.status}${j.message ?? ""}`,
);
}
}
// Check dashboard repos
const dashResp = await request.get(`${APP_URL}/api/dashboard`, {
headers: { Cookie: appCookies },
failOnStatusCode: false,
});
if (dashResp.ok()) {
const dashData = await dashResp.json();
const repos: any[] = dashData.repositories ?? [];
console.log(`[State] Dashboard repos: ${repos.length}`);
for (const r of repos) {
console.log(
`[State] • ${r.name}: status=${r.status}, mirrored=${r.mirroredLocation ?? "none"}`,
);
}
expect(repos.length, "Repos should exist in DB").toBeGreaterThan(0);
const succeeded = repos.filter(
(r: any) => r.status === "mirrored" || r.status === "success",
);
console.log(
`[State] Successfully mirrored repos: ${succeeded.length}/${repos.length}`,
);
}
// App should still be running
const healthResp = await request.get(`${APP_URL}/`, {
failOnStatusCode: false,
});
expect(
healthResp.status(),
"App should still be running after mirror attempts",
).toBeLessThan(500);
console.log(`[State] App health: ${healthResp.status()}`);
});
});