Files
sencho/e2e/git-sources.spec.ts
Anso ee62a449b2 refactor(gitops): organize the Git source sheet into tabs and make editing deliberate (#2064)
The sheet was one long scroll with the repository form always open and a
toolbar of five buttons. A linked source now has tabs:

- Overview: the status block, with Review update, Retry, or Resume in it.
- Source: a read-only summary of the configuration, an Edit button into
  the form (with Cancel), Adopt onto Blueprint, and the manifest.
- Automation: the apply behavior, Suspend or Resume, and the provider
  hooks.
- Secrets.
- Drift, only while the application has drift.

An unlinked stack gets no tabs, only the form. The toolbar keeps Pull now
and Detach (named the same as its confirmation); Update appears only
while something differs from what is stored, so it never sits there doing
nothing.

Reading and refreshing are more careful. A failed read now says so with a
Retry instead of presenting an empty "link a source" form; a change that
was saved but could not be re-read keeps the linked source on screen; a
pull, apply, suspend, or resume refreshes the status without discarding a
change that is still being made; and a slow answer for a stack the sheet
has left can no longer overwrite the one it shows. Detaching empties the
form instead of leaving the removed source prefilled.

Docs, the Git sources page, and the two end-to-end specs that drive the
sheet are updated.
2026-10-02 18:58:27 -04:00

877 lines
38 KiB
TypeScript

/**
* Git Sources E2E - configure, save, pull, detach.
*
* 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';
import { gitAvailable, buildFixtureRepo, serveRepos, fullProjectFiles, multiFileFiles, refusalFiles } from './gitServer.helper';
import { sshGitFixtureAvailable, startSshGitFixture, type SshGitE2eFixture } from './sshGit.helper';
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();
// Sheet chrome: heading plus crumb. The old centered modal used a kicker
// ("<STACK> · GIT SOURCE") that a plain getByText match would also hit.
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole('navigation', { name: /sheet location/i })).toContainText('Git source');
}
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 unsupported repository URL schemes client-side', async ({ page }) => {
await openGitSourcePanel(page);
await page.locator('#git-source-repo').fill('http://github.com/org/repo.git');
await page.locator('#git-source-branch').fill('main');
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
await expect(page.getByText(/Use an https:\/\/ URL or an SSH URL/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.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('PUT against a non-existent stack returns 404', async ({ page }) => {
const status = await page.evaluate(async () => {
const res = await fetch(`/api/stacks/nonexistent-ghost-stack/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
});
expect(status).toBe(404);
});
test('backend rejects http:// URLs on PUT with 400', async ({ page }) => {
const status = 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: 'http://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, TEST_STACK);
expect(status).toBe(400);
});
test('backend rejects .git/config as compose_path', async ({ page }) => {
const body = 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/example/repo.git',
branch: 'main',
compose_path: '.git/config',
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}, TEST_STACK);
expect(body.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(body.body)).toMatch(/\.git|file/i);
});
test('configure, view pending-empty state, and detach via AlertDialog', async ({ page }) => {
// Seed a git source directly via API so we can exercise the detach-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 detach flow to keep the suite robust.
if (putStatus >= 400) {
test.skip(true, `Upstream dry-run returned ${putStatus}; skipping detach path`);
return;
}
await openGitSourcePanel(page);
// The Source tab reads out the saved repo URL; editing is a deliberate step.
await page.getByRole('tab', { name: 'Source' }).click();
await expect(page.getByTestId('git-source-summary')).toContainText(/awesome-compose/);
// Click Detach → AlertDialog appears → confirm → source cleared.
await page.getByRole('dialog').getByRole('button', { name: 'Detach', exact: true }).click();
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
await page.getByRole('alertdialog').getByRole('button', { name: /^Detach$/ }).click();
// After detach, the "Detach" button is gone from the confirm dialog.
await expect(page.getByRole('dialog').getByRole('button', { name: /^Detach$/ })).not.toBeVisible({ timeout: 5_000 });
});
test('the automation tab offers suspend and resume for a linked source', async ({ page }) => {
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);
if (putStatus >= 400) {
test.skip(true, `Upstream dry-run returned ${putStatus}; skipping suspend path`);
return;
}
await openGitSourcePanel(page);
await expect(page.getByRole('navigation', { name: /sheet location/i })).toContainText(TEST_STACK);
// Suspend and resume are controls of the source's automation.
await page.getByRole('tab', { name: 'Automation' }).click();
await expect(page.getByRole('button', { name: /^suspend$/i })).toBeVisible();
await page.getByRole('button', { name: /^suspend$/i }).click();
const suspendDialog = page.getByRole('alertdialog');
await expect(suspendDialog).toBeVisible({ timeout: 5_000 });
await suspendDialog.getByRole('button', { name: /^suspend$/i }).click();
await expect(page.getByRole('button', { name: /^resume$/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: /^suspend$/i })).toHaveCount(0);
await page.getByRole('button', { name: /^resume$/i }).click();
await expect(page.getByText(/reconciliation resumed/i)).toBeVisible({ timeout: 10_000 });
// Resume kicks evaluateNow, which is fetching for a while. availableActions
// is empty during that fetch, so wait for the source to leave checking
// before asserting Suspend is back.
await expect.poll(async () => {
const body = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' });
return res.json();
}, TEST_STACK) as { gitopsRevision?: { facets?: { source?: { status?: string } } } };
const status = body.gitopsRevision?.facets?.source?.status;
return Boolean(status && status !== 'checking_fetching' && status !== 'applying');
}, { timeout: 30_000, intervals: [1_000] }).toBeTruthy();
await page.keyboard.press('Escape');
await openGitSourcePanel(page);
await page.getByRole('tab', { name: 'Automation' }).click();
await expect(page.getByRole('button', { name: /^suspend$/i })).toBeVisible({ timeout: 10_000 });
});
});
const CREATE_FROM_GIT_STACK = 'e2e-create-from-git';
async function deleteCreateFromGitStack(page: Page) {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, CREATE_FROM_GIT_STACK);
}
async function openCreateStackDialog(page: Page) {
await page.getByRole('button', { name: 'Create Stack' }).click();
await expect(page.getByRole('dialog', { name: 'New stack' })).toBeVisible({ timeout: 5_000 });
}
test.describe('Create stack from Git', () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteCreateFromGitStack(page);
await page.close();
});
test.afterAll(async ({ browser }) => {
const page = await browser.newPage();
await loginAs(page);
await deleteCreateFromGitStack(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('dialog exposes Empty and From Git tabs', async ({ page }) => {
await openCreateStackDialog(page);
await expect(page.getByRole('dialog').getByRole('tab', { name: /Empty/i })).toBeVisible();
await expect(page.getByRole('dialog').getByRole('tab', { name: /From Git/i })).toBeVisible();
});
test('From Git tab rejects unsupported URL schemes client-side', async ({ page }) => {
await openCreateStackDialog(page);
await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click();
await page.locator('#create-git-stack-name').fill(CREATE_FROM_GIT_STACK);
await page.locator('#git-source-repo').fill('http://github.com/org/repo.git');
await page.locator('#git-source-branch').fill('main');
await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click();
await expect(page.getByText(/Use an https:\/\/ URL or an SSH URL/i)).toBeVisible({ timeout: 5_000 });
});
test('backend rejects .git/config compose_path on from-git', async ({ page }) => {
const body = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/from-git`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
stack_name: name,
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: '.git/config',
auth_type: 'none',
}),
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}, CREATE_FROM_GIT_STACK);
expect(body.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(body.body)).toMatch(/\.git|file/i);
});
test('happy path: fetches compose, creates stack, links git source', async ({ page }) => {
// Use a public demo repo. If network egress is blocked, the POST fails
// and we skip the rest of the test rather than hanging the suite.
const result = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/from-git`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
stack_name: name,
repo_url: 'https://github.com/docker/awesome-compose.git',
branch: 'master',
compose_path: 'nginx-golang/compose.yaml',
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
deploy_now: false,
}),
});
return { status: res.status, body: await res.json().catch(() => ({})) };
}, CREATE_FROM_GIT_STACK);
if (result.status >= 400) {
test.skip(true, `Upstream unreachable (status ${result.status}); skipping happy path`);
return;
}
expect(result.status).toBe(200);
expect(result.body?.source?.stack_name).toBe(CREATE_FROM_GIT_STACK);
expect(result.body?.source?.last_applied_commit_sha).toBeTruthy();
// Stack dir should now exist and the compose should contain the upstream service names.
const contentStatus = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}`, { credentials: 'include' });
return { status: res.status, body: await res.text() };
}, CREATE_FROM_GIT_STACK);
expect(contentStatus.status).toBe(200);
expect(contentStatus.body).toMatch(/services:/);
// Backend contract: commitSha is returned at full length so the frontend
// can build the short-SHA suffix for the success toast. Guard it here so
// the toast copy can never drift without a test catching it.
expect(result.body?.commitSha).toMatch(/^[0-9a-f]{40}$/);
});
test('UI flow: success toast includes the short commit SHA', async ({ page }) => {
// Pre-flight check: if the upstream is unreachable from this runner, the
// UI flow will also fail. Probe the API with a throwaway name first so we
// skip cleanly instead of hanging on a dialog that never resolves.
const probeName = `${CREATE_FROM_GIT_STACK}-probe`;
const probe = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/from-git`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
stack_name: name,
repo_url: 'https://github.com/docker/awesome-compose.git',
branch: 'master',
compose_path: 'nginx-golang/compose.yaml',
auth_type: 'none',
deploy_now: false,
}),
});
return { status: res.status };
}, probeName);
// Always tear down the probe, whether it succeeded or not.
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, probeName);
if (probe.status >= 400) {
test.skip(true, `Upstream unreachable (status ${probe.status}); skipping UI toast test`);
return;
}
const uiName = `${CREATE_FROM_GIT_STACK}-ui`;
// Ensure no leftover row from a prior failing run.
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, uiName);
try {
await openCreateStackDialog(page);
await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click();
await page.locator('#create-git-stack-name').fill(uiName);
await page.locator('#git-source-repo').fill('https://github.com/docker/awesome-compose.git');
await page.locator('#git-source-branch').fill('master');
// Drive the compose-file picker: add the repo path, then drop the default
// compose.yaml so only the intended file is deployed.
const createDialog = page.getByRole('dialog');
await createDialog.getByPlaceholder('path/to/compose.yaml').fill('nginx-golang/compose.yaml');
await createDialog.getByPlaceholder('path/to/compose.yaml').press('Enter');
await createDialog.getByRole('button', { name: 'Remove compose.yaml' }).click();
await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click();
// The toast copy is "Stack created from Git @ <short sha>." — match the
// @-delimited 7-char hex suffix so any drift in wording still passes as
// long as the SHA is surfaced.
await expect(page.getByText(/@ [0-9a-f]{7}/).first()).toBeVisible({ timeout: 20_000 });
} finally {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, uiName);
}
});
});
test.describe('Git Sources complete-project materialization (local git server)', () => {
test.skip(!gitAvailable(), 'system git binary is not available');
let server: { url: string; close: () => void };
let stackName: string;
test.beforeAll(async () => {
server = await serveRepos({
app: buildFixtureRepo(fullProjectFiles()),
multi: buildFixtureRepo(multiFileFiles()),
bad: buildFixtureRepo(refusalFiles()),
});
});
test.afterAll(() => {
server?.close();
});
test.beforeEach(async () => {
stackName = `e2e-mater-${Date.now()}`;
});
test.afterEach(async ({ page }) => {
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
function saveSource(page: Page, repoUrl: string, composePaths: string[], contextDir: string | null = null) {
return page.evaluate(async ({ name, repoUrl, composePaths, contextDir }) => {
const res = await fetch(`/api/stacks/${name}/git-source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
repo_url: repoUrl,
branch: 'main',
compose_paths: composePaths,
context_dir: contextDir,
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
}),
});
return res.status;
}, { name: stackName, repoUrl, composePaths, contextDir });
}
test('materializes the complete project and records it in the manifest', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
const repoUrl = `${server.url}/app.git`;
expect(await saveSource(page, repoUrl, ['compose.yaml'])).toBe(200);
const pull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' });
return { status: res.status, body: await res.json() };
}, stackName);
expect(pull.status, JSON.stringify(pull.body)).toBe(200);
expect(pull.body.candidateReady).toBe(true);
expect(pull.body.plan).toBeTruthy();
expect(JSON.stringify(pull.body)).not.toContain('incomingCompose');
const applied = await page.evaluate(async ({ name, sha, fp }) => {
const res = await fetch(`/api/stacks/${name}/git-source/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }),
});
return { status: res.status, body: await res.json() };
}, { name: stackName, sha: pull.body.commitSha, fp: pull.body.planFingerprint });
expect(applied.status).toBe(200);
expect(applied.body.applied).toBe(true);
// The managed-project manifest records the complete materialized set.
const manifest = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/manifest`, { credentials: 'include' });
return res.ok ? await res.json() : null;
}, stackName);
expect(manifest).not.toBeNull();
const kinds = manifest.manifest.inputs.map((i: { dependencyKind: string }) => i.dependencyKind);
expect(kinds).toContain('config');
expect(kinds).toContain('env_file');
expect(kinds).toContain('build-context');
expect(manifest.manifest.state).toBe('active');
});
test('refuses to overwrite local modifications on apply', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
const repoUrl = `${server.url}/app.git`;
expect(await saveSource(page, repoUrl, ['compose.yaml'])).toBe(200);
const pull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' });
return await res.json();
}, stackName);
const applied = await page.evaluate(async ({ name, sha, fp }) => {
const res = await fetch(`/api/stacks/${name}/git-source/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }),
});
return res.status;
}, { name: stackName, sha: pull.commitSha, fp: pull.planFingerprint });
expect(applied).toBe(200);
// Locally modify a managed input through the file editor API.
const writeStatus = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/files/content?path=web.env`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content: 'FOO=locally-edited\n' }),
});
return res.status;
}, stackName);
expect([200, 204]).toContain(writeStatus);
const secondPull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' });
return await res.json();
}, stackName);
const refused = await page.evaluate(async ({ name, sha, fp }) => {
const res = await fetch(`/api/stacks/${name}/git-source/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }),
});
return { status: res.status, body: await res.json() };
}, { name: stackName, sha: secondPull.commitSha, fp: secondPull.planFingerprint });
expect(refused.status).toBe(409);
expect(refused.body.code).toBe('PLAN_BLOCKED');
const stillLocal = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/files/content?path=web.env`, { credentials: 'include' });
return res.ok ? await res.text() : '';
}, stackName);
expect(stillLocal).toContain('locally-edited');
});
test('detaches a multi-file stack with the export contract', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
const repoUrl = `${server.url}/multi.git`;
expect(await saveSource(page, repoUrl, ['deploy/base.yaml', 'deploy/prod.yaml'], 'deploy')).toBe(200);
const pull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' });
return await res.json();
}, stackName);
const applied = await page.evaluate(async ({ name, sha, fp }) => {
const res = await fetch(`/api/stacks/${name}/git-source/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ commitSha: sha, planFingerprint: fp, deploy: false }),
});
return res.status;
}, { name: stackName, sha: pull.commitSha, fp: pull.planFingerprint });
expect(applied).toBe(200);
const detached = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' });
return { status: res.status, body: await res.json() };
}, stackName);
expect(detached.status).toBe(200);
// The stack still deploys as a plain compose project: the effective model
// was exported into compose.yaml and the source row is gone.
const after = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' });
return { sourceStatus: res.status, body: await res.json() };
}, stackName);
expect(after.sourceStatus).toBe(200);
// Asserted field by field rather than by whole-object equality: the
// response also carries the additive GitOps revision fields, and a detached
// stack has no application to project while its directory is still on disk.
expect(after.body.linked).toBe(false);
expect(after.body.stackResourcePresent).toBe(true);
expect(after.body.gitopsRevision).toMatchObject({
schemaVersion: 1,
targetMode: 'not_applicable',
applicationId: null,
});
const exported = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/files/content?path=compose.yaml`, { credentials: 'include' });
return res.ok ? await res.text() : '';
}, stackName);
expect(exported).toContain('image: nginx');
});
test('surfaces the refusal callout for an out-of-bound include on pull', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
const repoUrl = `${server.url}/bad.git`;
expect(await saveSource(page, repoUrl, ['compose.yaml'])).toBe(200);
const pull = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source/pull`, { method: 'POST', credentials: 'include' });
return { status: res.status, body: await res.json() };
}, stackName);
// The actionable refusal aborts the pull with an actionable message.
expect(pull.status).toBe(400);
expect(JSON.stringify(pull.body)).toMatch(/outside the repository|Cannot materialize/);
});
test('shows a classified plan in the review dialog and keeps Apply reachable on a phone', async ({ page }) => {
await loginAs(page);
await page.evaluate(async (name) => {
await fetch('/api/stacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ stackName: name }),
});
}, stackName);
expect(await saveSource(page, `${server.url}/app.git`, ['compose.yaml'])).toBe(200);
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 15_000 });
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await page.getByRole('button', { name: /Pull now/i }).click();
await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 20_000 });
const applyBtn = page.getByRole('button', { name: /^Apply$/ });
await expect(applyBtn).toBeEnabled();
await page.setViewportSize({ width: 375, height: 812 });
await expect(applyBtn).toBeVisible();
});
});
test.describe('Git source suspend and resume over the API (local git server)', () => {
test.skip(!gitAvailable(), 'system git binary is not available');
// The whole journey is one scenario with wall-clock steps (an unattended
// poll fires on a 60s floor), so it gets a longer timeout than the suite
// default rather than being split into state-leaking fragments.
test.setTimeout(180_000);
let server: { url: string; close: () => void };
let stackName: string;
let previousPollIntervalMins: number;
test.beforeAll(async () => {
server = await serveRepos({
sync: buildFixtureRepo({ 'compose.yaml': 'services:\n web:\n image: nginx\n' }),
});
});
test.afterAll(() => {
server?.close();
});
async function api(page: Page, path: string, init?: { method?: string; body?: unknown }) {
return page.evaluate(async ({ p, opts }) => {
const res = await fetch(p, {
method: opts?.method ?? 'GET',
headers: opts?.body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: opts?.body !== undefined ? JSON.stringify(opts.body) : undefined,
credentials: 'include',
});
let json: unknown = null;
try { json = await res.json(); } catch { /* empty or non-JSON body */ }
return { status: res.status, json };
}, { p: path, opts: init });
}
/** History entries for this stack, newest page first, as { stage, trigger, outcome }. */
async function historyEntries(page: Page): Promise<Array<{ stage: string; trigger: string; outcome: string }>> {
const res = await api(page, `/api/stacks/${stackName}/git-source/history?limit=100`);
const items = (res.json as { items?: Array<Record<string, unknown>> } | null)?.items ?? [];
return items.map((i) => ({
stage: String(i.stage ?? ''),
trigger: String(i.trigger ?? ''),
outcome: String(i.outcome ?? ''),
}));
}
test('suspension stops reconciliation and resume re-evaluates the source immediately', async ({ page }) => {
await loginAs(page);
stackName = `e2e-susp-${Date.now()}`;
// Park the unattended cadence on the slowest floor the settings allow
// (60s effective) and restore whatever the instance had before, so the
// scenario never races a background poll and never changes the box.
previousPollIntervalMins = ((await api(page, '/api/git-sources/polling')).json as { poll_interval_mins?: number })?.poll_interval_mins ?? 0;
await api(page, '/api/git-sources/polling', { method: 'PATCH', body: { poll_interval_mins: 1 } });
try {
expect((await api(page, '/api/stacks', { method: 'POST', body: { stackName } })).status).toBe(200);
const saved = await api(page, `/api/stacks/${stackName}/git-source`, {
method: 'PUT',
body: {
repo_url: `${server.url}/sync.git`,
branch: 'main',
compose_paths: ['compose.yaml'],
context_dir: null,
sync_env: false,
auth_type: 'none',
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
source_policy: 'automatic',
},
});
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
// A manual pull proves the source is live and records a manual-
// trigger attempt before the suspension journey starts.
const pulled = await api(page, `/api/stacks/${stackName}/git-source/pull`, { method: 'POST' });
expect(pulled.status, JSON.stringify(pulled.json)).toBe(200);
const suspended = await api(page, `/api/stacks/${stackName}/git-source/suspend`, {
method: 'POST',
body: { reason: 'e2e maintenance hold' },
});
expect(suspended.status).toBe(200);
expect((suspended.json as { outcome: string }).outcome).toBe('suspended');
// While suspended, a pull is refused outright, not silently skipped.
const refused = await api(page, `/api/stacks/${stackName}/git-source/pull`, { method: 'POST' });
expect(refused.status).toBe(409);
expect((refused.json as { code: string }).code).toBe('OPERATION_IN_FLIGHT');
// resume() itself stamps 'resume' on the suspension transition and
// the armed poll cursor, so only a reconcile-start row can prove the
// immediate re-evaluation actually fired.
const reconcileStarts = (rows: Array<{ stage: string; trigger: string }>) =>
rows.filter((e) => e.stage === 'source_reconcile_started' && e.trigger === 'resume').length;
expect(reconcileStarts(await historyEntries(page))).toBe(0);
const resumed = await api(page, `/api/stacks/${stackName}/git-source/resume`, { method: 'POST' });
expect(resumed.status, JSON.stringify(resumed.json)).toBe(200);
expect((resumed.json as { outcome: string }).outcome).not.toBe('suspended');
// The response returns before the reconcile it kicked off can finish,
// so poll the history until the resume-trigger reconcile lands: proof
// the resumed source was re-evaluated now rather than at its next
// scheduled cursor (which is a minute away).
await expect.poll(async () => {
return reconcileStarts(await historyEntries(page));
}, { timeout: 30_000, intervals: [1_000] }).toBeGreaterThan(0);
} finally {
await api(page, `/api/stacks/${stackName}/git-source`, { method: 'DELETE' }).catch(() => {});
await api(page, `/api/stacks/${stackName}`, { method: 'DELETE' }).catch(() => {});
await api(page, '/api/git-sources/polling', {
method: 'PATCH',
body: { poll_interval_mins: previousPollIntervalMins },
}).catch(() => {});
}
});
});
test.describe('Git Sources SSH deploy key (real backend)', () => {
test.skip(!sshGitFixtureAvailable(), 'requires git and openssh-server');
let fixture: SshGitE2eFixture;
test.beforeAll(async ({ browser }) => {
fixture = await startSshGitFixture(22224);
const page = await browser.newPage();
await loginAs(page);
await deleteTestStackViaApi(page);
await createTestStackViaApi(page);
await page.close();
});
test.afterAll(async ({ browser }) => {
fixture?.close();
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('probes host key, saves deploy key, and redacts credentials on GET', async ({ page }) => {
await openGitSourcePanel(page);
await page.locator('#git-source-repo').fill(fixture.repoUrlSsh);
await page.locator('#git-source-branch').fill('main');
await page.getByRole('button', { name: 'Deploy key (SSH)' }).click();
await page.getByRole('button', { name: 'Fetch host key fingerprint' }).click();
await expect(page.getByText(fixture.firstFingerprint, { exact: true })).toBeVisible({ timeout: 15_000 });
await page.locator('textarea').fill(fixture.deployPrivateKey);
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
await expect(page.getByText('Git source saved.')).toBeVisible({ timeout: 30_000 });
const getBody = await page.evaluate(async (name) => {
const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' });
return res.json();
}, TEST_STACK);
expect(getBody.auth_type).toBe('deploy_key');
expect(getBody.has_deploy_key).toBe(true);
expect(getBody.ssh_host_key_fingerprint).toBe(fixture.firstFingerprint);
expect(JSON.stringify(getBody)).not.toContain('BEGIN OPENSSH');
});
});