Files
sencho/e2e/git-sources.spec.ts
T
Anso 377df7e546 feat(git-sources): link stacks to Git repositories with diff-and-apply workflow (#600)
* feat(git-sources): link stacks to Git repositories with diff-and-apply workflow

Add Git Sources so any stack can point at an HTTPS Git repository, branch, and
compose file path. Pulls fetch + validate the incoming commit, store a
diffable pending snapshot, and apply writes only after explicit confirmation
(or automatically, per the configured apply mode). Sibling .env sync is
optional. Works on the Community tier.

Apply modes:
- Review only: mark pending, wait for manual apply in the diff dialog
- Auto-write: write compose + env to disk, do not redeploy
- Auto-deploy: write files and run docker compose up -d

Webhook integration: webhooks can target the new "git-pull" action to trigger
a sync from CI. Per-source debounce prevents runaway pipelines from hammering
the repository host. Tokens are encrypted at rest and never returned to the
frontend.

Docs and tests included. Screenshots and Playwright E2E flows to follow.

* fix(git-sources): drop unnecessary useMemo on commit sha slice

React Compiler's lint rule rejected the manual dependency list because the
inferred dep ('pull') was less specific than the written one ('pull?.commitSha').
The computation is a cheap 7-char slice, so drop the useMemo entirely rather
than fight the rule.

* test(git-sources): add Playwright E2E flows and drop orphan source rows on stack delete

- E2E coverage: non-HTTPS URL rejected client-side, unreachable repo surfaces
  a toast error on save, and configure+remove walks the AlertDialog confirm path.
- Deleting a stack now also drops its linked Git source row so a future stack
  with the same name starts clean rather than inheriting a stale config.
2026-04-14 21:13:17 -04:00

137 lines
5.5 KiB
TypeScript

/**
* Git Sources E2E - configure, save, pull, remove.
*
* These tests use a throwaway stack that is created via the browser's
* authenticated fetch (so cookies are carried) and cleaned up in afterAll.
* Pull tests use an unreachable URL on purpose so the suite does not depend
* on real network egress or a specific upstream repo being available.
*/
import { test, expect, Page } from '@playwright/test';
import { loginAs } from './helpers';
const TEST_STACK = 'e2e-git-source-stack';
async function createTestStackViaApi(page: Page) {
return page.evaluate(async (name) => {
const res = await fetch(`/api/stacks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
return res.status;
}, TEST_STACK);
}
async function deleteTestStackViaApi(page: Page) {
await page.evaluate(async (name) => {
// Drop any orphaned git-source row first (safe even if the stack is already gone).
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, TEST_STACK);
}
async function openGitSourcePanel(page: Page) {
await page.getByText(TEST_STACK).first().click();
const gitBtn = page.getByRole('button', { name: /Git Source/i });
await expect(gitBtn).toBeVisible({ timeout: 10_000 });
await gitBtn.click();
await expect(page.getByRole('dialog').getByText('Git Source', { exact: false })).toBeVisible({ timeout: 5_000 });
}
test.describe('Git Sources', () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteTestStackViaApi(page);
await createTestStackViaApi(page);
await page.close();
});
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteTestStackViaApi(page);
await page.close();
});
test.beforeEach(async ({ page }) => {
await loginAs(page);
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
});
test('rejects non-HTTPS repository URLs client-side', async ({ page }) => {
await openGitSourcePanel(page);
await page.locator('#git-source-repo').fill('git@github.com:org/repo.git');
await page.locator('#git-source-branch').fill('main');
await page.locator('#git-source-path').fill('compose.yaml');
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 });
});
test('surfaces reachability error on save with unreachable repo', async ({ page }) => {
await openGitSourcePanel(page);
// Use a URL that resolves but returns 404 for the git protocol so the dry-run
// fetch fails with a clean error. reserved-TLDs like .invalid trigger DNS failure
// which maps to NETWORK_TIMEOUT or REPO_NOT_FOUND.
await page.locator('#git-source-repo').fill('https://git.invalid.example/nope/nope.git');
await page.locator('#git-source-branch').fill('main');
await page.locator('#git-source-path').fill('compose.yaml');
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
// Any of the mapped error messages is acceptable; the key is that nothing
// persisted silently and the user sees a toast.
await expect(
page.getByText(/not found|unreachable|network|timeout|authentication failed/i).first(),
).toBeVisible({ timeout: 15_000 });
});
test('configure, view pending-empty state, and remove via AlertDialog', async ({ page }) => {
// Seed a git source directly via API so we can exercise the remove-confirm
// flow without depending on a reachable upstream.
const putStatus = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/docker/awesome-compose.git',
branch: 'master',
compose_path: 'nginx-golang/compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, TEST_STACK);
// Either the dry-run succeeded (2xx) or the network blocked it (4xx/5xx).
// If it failed, skip the rest of the remove flow to keep the suite robust.
if (putStatus >= 400) {
test.skip(true, `Upstream dry-run returned ${putStatus}; skipping remove path`);
return;
}
await openGitSourcePanel(page);
// Source should render with the saved repo URL.
await expect(page.locator('#git-source-repo')).toHaveValue(/awesome-compose/);
// Click Remove → AlertDialog appears → confirm → source cleared.
await page.getByRole('dialog').getByRole('button', { name: /Remove/i }).click();
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
await page.getByRole('alertdialog').getByRole('button', { name: /^Remove$/ }).click();
// After removal, the "Remove" button is gone from the panel footer.
await expect(page.getByRole('dialog').getByRole('button', { name: /^Remove$/ })).not.toBeVisible({ timeout: 5_000 });
});
});