diff --git a/tests/e2e/changes-commit.spec.ts b/tests/e2e/changes-commit.spec.ts
index f45eee6..8676819 100644
--- a/tests/e2e/changes-commit.spec.ts
+++ b/tests/e2e/changes-commit.spec.ts
@@ -4,6 +4,8 @@ import { AUTH_STATE, expectAccessible, SAMPLE_REMOTE, SAMPLE_STATE } from './sup
test.use({ storageState: AUTH_STATE });
test.skip(!SAMPLE_REMOTE, 'Set E2E_GIT_REMOTE to run the Changes commit tests.');
+// Playwright runs this file in dedicated projects after source/shell (see playwright.config.ts).
+test.describe.configure({ mode: 'serial' });
const isMobile = (page: Page) => (page.viewportSize()?.width ?? 1280) <= 900;
const sample = () =>
@@ -13,25 +15,34 @@ const sample = () =>
stackId: string;
};
const stackUrl = () => `/w/${sample().workspaceId}/stacks/${sample().stackId}`;
+const changesUrl = () => `${stackUrl()}/changes`;
const identityHref = () => `/w/${sample().workspaceId}/settings/account#ws-account-git-identity`;
-async function openChanges(page: Page) {
- await page
- .getByRole('navigation', { name: 'Stack' })
- .getByRole('link', { name: /Changes/ })
- .click();
- await expect(page).toHaveURL(/\/changes$/);
-}
-
+/** Saves a unique marker via the drafts API (avoids CodeMirror/mobile input flakiness). */
async function saveDraftMarker(page: Page, marker: string) {
- await page.goto(stackUrl());
- const editor = page.locator('.cm-content');
- await expect(editor).toBeVisible();
- await editor.click();
- await page.keyboard.press('ControlOrMeta+End');
- await page.keyboard.type(`\n# ${marker}\n`);
- await page.keyboard.press('ControlOrMeta+s');
- await expect(page.locator('.editor-toolbar').getByText('Draft', { exact: true })).toBeVisible();
+ const { workspaceId, stackId } = sample();
+ if (!/^https?:\/\//.test(page.url())) await page.goto(stackUrl());
+ const origin = new URL(page.url()).origin;
+ const headers = { Origin: origin };
+ const stackRes = await page.request.get(`/api/workspaces/${workspaceId}/stacks/${stackId}`);
+ expect(stackRes.status(), await stackRes.text()).toBe(200);
+ const { stack } = (await stackRes.json()) as { stack: { composePath: string } };
+ const path = stack.composePath;
+ const fileRes = await page.request.get(
+ `/api/workspaces/${workspaceId}/stacks/${stackId}/files?path=${encodeURIComponent(path)}`,
+ );
+ expect(fileRes.status(), await fileRes.text()).toBe(200);
+ const { file } = (await fileRes.json()) as {
+ file: { content: string | null; blobSha: string | null; draft: { content: string } | null };
+ };
+ // Always restart from the committed blob so a prior validation test cannot leave broken YAML.
+ const base = file.content ?? '';
+ const content = `${base.replace(/\n$/, '')}\n# ${marker}\n`;
+ const put = await page.request.put(`/api/workspaces/${workspaceId}/stacks/${stackId}/drafts`, {
+ headers,
+ data: { path, content, baseBlobSha: file.blobSha },
+ });
+ expect(put.status(), await put.text()).toBe(200);
}
async function setGitIdentity(page: Page, name = 'E2E Operator', email = 'e2e@example.invalid') {
@@ -43,34 +54,37 @@ async function setGitIdentity(page: Page, name = 'E2E Operator', email = 'e2e@ex
expect(res.status(), await res.text()).toBe(200);
}
-/** Clears stack drafts via the API so cleanup is not blocked by overlapping UI chrome. */
-async function discardAllDrafts(page: Page) {
+async function draftCount(page: Page) {
const { workspaceId, stackId } = sample();
- if (!/^https?:\/\//.test(page.url())) await page.goto(stackUrl());
- const origin = new URL(page.url()).origin;
const listed = await page.request.get(`/api/workspaces/${workspaceId}/stacks/${stackId}/drafts`);
expect(listed.status(), await listed.text()).toBe(200);
- const { changes } = (await listed.json()) as { changes: Array<{ path: string }> };
- for (const change of changes) {
- const res = await page.request.delete(
- `/api/workspaces/${workspaceId}/stacks/${stackId}/drafts?path=${encodeURIComponent(change.path)}`,
- { headers: { Origin: origin } },
- );
- expect(res.ok(), await res.text()).toBeTruthy();
+ return ((await listed.json()) as { changes: unknown[] }).changes.length;
+}
+
+/** Drafts must still exist after a failed/retained Git operation (cross-worker safe). */
+async function expectWorkPreserved(page: Page) {
+ expect(await draftCount(page)).toBeGreaterThan(0);
+ await expect(page.locator('.diff').first()).toBeVisible();
+}
+
+async function openChangesWithDraft(page: Page, marker: string) {
+ await saveDraftMarker(page, marker);
+ await page.goto(changesUrl());
+ if (await page.getByRole('heading', { name: 'No draft changes' }).isVisible()) {
+ await saveDraftMarker(page, marker);
+ await page.goto(changesUrl());
}
+ await expect(page.locator('.diff').first()).toBeVisible();
}
test('changes shows diffs, commit form, missing identity and is accessible', async ({ page }) => {
test.skip(isMobile(page), 'Desktop covers the full commit form; mobile has a dedicated layout test.');
const marker = `commit-ui-${Date.now()}`;
const { repositoryId } = sample();
- // Shared e2e DB may already have an identity from parallel projects; drive the outcome via the API contract.
await setGitIdentity(page);
- await saveDraftMarker(page, marker);
- await page.goto(changesUrl());
+ await openChangesWithDraft(page, marker);
await expect(page.getByRole('status').filter({ hasText: /draft/ })).toContainText(/draft/);
- await expect(page.locator('.diff')).toContainText(marker);
await expect(page.getByRole('region', { name: 'Commit changes' })).toBeVisible();
await expect(page.getByLabel('Commit message')).toBeVisible();
@@ -101,52 +115,58 @@ test('changes shows diffs, commit form, missing identity and is accessible', asy
identityHref(),
);
await expect(page.getByText('git_identity_missing')).toHaveCount(0);
-
+ await expectWorkPreserved(page);
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
await page.unroute(`**/repositories/${repositoryId}/git/commit`);
- await discardAllDrafts(page);
});
test('validation errors block commit and link back to the file', async ({ page }) => {
test.skip(isMobile(page), 'Validation coverage runs once on desktop.');
await setGitIdentity(page);
- await page.goto(stackUrl());
- const editor = page.locator('.cm-content');
- await expect(editor).toBeVisible();
- await editor.click();
- await page.keyboard.press('ControlOrMeta+a');
- await page.keyboard.type('services:\n app:\n image: [broken\n');
- await page.keyboard.press('ControlOrMeta+s');
- await expect(page.locator('.editor-toolbar').getByText('Draft', { exact: true })).toBeVisible();
+ const { workspaceId, stackId } = sample();
+ if (!/^https?:\/\//.test(page.url())) await page.goto(stackUrl());
+ const origin = new URL(page.url()).origin;
+ const stackRes = await page.request.get(`/api/workspaces/${workspaceId}/stacks/${stackId}`);
+ const { stack } = (await stackRes.json()) as { stack: { composePath: string } };
+ const fileRes = await page.request.get(
+ `/api/workspaces/${workspaceId}/stacks/${stackId}/files?path=${encodeURIComponent(stack.composePath)}`,
+ );
+ const { file } = (await fileRes.json()) as {
+ file: { blobSha: string | null };
+ };
+ const put = await page.request.put(`/api/workspaces/${workspaceId}/stacks/${stackId}/drafts`, {
+ headers: { Origin: origin },
+ data: {
+ path: stack.composePath,
+ content: 'services:\n app:\n image: [broken\n',
+ baseBlobSha: file.blobSha,
+ },
+ });
+ expect(put.status(), await put.text()).toBe(200);
- await openChanges(page);
+ await page.goto(changesUrl());
await expect(page.getByText(/blocking error/)).toBeVisible();
await expect(page.getByRole('button', { name: 'Commit', exact: true })).toBeDisabled();
await expect(page.getByText('Validation blocked')).toBeVisible();
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
-
- await discardAllDrafts(page);
});
test('successful commit refreshes and keeps drafts visible', async ({ page }) => {
test.skip(isMobile(page), 'Commit success runs once on desktop.');
await setGitIdentity(page);
const marker = `committed-${Date.now()}`;
- await saveDraftMarker(page, marker);
- // Identity is read on the server; reload Changes after setting it.
- await page.goto(changesUrl());
- await expect(page.locator('.diff')).toContainText(marker);
+ await openChangesWithDraft(page, marker);
await page.getByLabel('Commit message').fill(`e2e: ${marker}`);
await expect(page.getByRole('button', { name: 'Commit', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Commit', exact: true }).click();
await expect(page.getByRole('status').filter({ hasText: /Committed as/ })).toBeVisible();
- await expect(page.locator('.diff')).toContainText(marker);
- await expect(page.getByRole('status').filter({ hasText: /draft/ })).toContainText(/draft/);
+ await expectWorkPreserved(page);
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
-
- await discardAllDrafts(page);
});
test('remote-changed push keeps work and offers fetch', async ({ page }) => {
@@ -154,8 +174,7 @@ test('remote-changed push keeps work and offers fetch', async ({ page }) => {
await setGitIdentity(page);
const marker = `push-fail-${Date.now()}`;
const { repositoryId } = sample();
- await saveDraftMarker(page, marker);
- await page.goto(changesUrl());
+ await openChangesWithDraft(page, marker);
await page.route(`**/repositories/${repositoryId}/git/commit`, async (route) => {
if (route.request().method() !== 'POST') return route.continue();
@@ -192,11 +211,10 @@ test('remote-changed push keeps work and offers fetch', async ({ page }) => {
await expect(page.getByText('The remote branch changed since your copy was last updated.')).toBeVisible();
await expect(page.getByText(/drafts are still safe/i)).toBeVisible();
await expect(page.getByRole('link', { name: 'Open repository to fetch' })).toBeVisible();
- await expect(page.locator('.diff')).toContainText(marker);
+ await expectWorkPreserved(page);
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
-
await page.unroute(`**/repositories/${repositoryId}/git/commit`);
- await discardAllDrafts(page);
});
test('mocked push success shows branch confirmation', async ({ page }) => {
@@ -204,8 +222,7 @@ test('mocked push success shows branch confirmation', async ({ page }) => {
await setGitIdentity(page);
const marker = `push-ok-${Date.now()}`;
const { repositoryId } = sample();
- await saveDraftMarker(page, marker);
- await page.goto(changesUrl());
+ await openChangesWithDraft(page, marker);
const sha = 'd'.repeat(40);
await page.route(`**/repositories/${repositoryId}/git/commit`, async (route) => {
@@ -237,33 +254,24 @@ test('mocked push success shows branch confirmation', async ({ page }) => {
await page.getByLabel('Commit message').fill(`e2e: ${marker}`);
await page.getByRole('button', { name: 'Commit & Push' }).click();
await expect(page.getByRole('status').filter({ hasText: /Pushed ddddddd to main/ })).toBeVisible();
- await expect(page.locator('.diff')).toContainText(marker);
+ await expectWorkPreserved(page);
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
-
await page.unroute(`**/repositories/${repositoryId}/git/commit`);
- await discardAllDrafts(page);
});
test('mobile: changes commit layout and accessibility', async ({ page }) => {
test.skip(!isMobile(page), 'Narrow layout only.');
await setGitIdentity(page);
const marker = `mobile-${Date.now()}`;
- await saveDraftMarker(page, marker);
- await page.goto(changesUrl());
+ await openChangesWithDraft(page, marker);
await expect(page.getByRole('region', { name: 'Commit changes' })).toBeVisible();
await expect(page.getByLabel('Commit message')).toBeVisible();
await expect(page.getByRole('button', { name: 'Commit', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Commit & Push' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Discard…' }).first()).toBeVisible();
- await expect(page.locator('.diff')).toContainText(marker);
+ await expectWorkPreserved(page);
+ await expect(page).toHaveTitle(/Changes/);
await expectAccessible(page);
-
- await discardAllDrafts(page);
- await page.goto(changesUrl());
- await expect(page.getByRole('heading', { name: 'No draft changes' })).toBeVisible();
});
-
-function changesUrl() {
- return `${stackUrl()}/changes`;
-}
diff --git a/tests/e2e/source.spec.ts b/tests/e2e/source.spec.ts
index 93f9668..61d72d1 100644
--- a/tests/e2e/source.spec.ts
+++ b/tests/e2e/source.spec.ts
@@ -231,6 +231,8 @@ test('repository page pages through Compose folders and adds them all', async ({
});
test('repository Files, Docs and Changes tabs cover files outside stacks', async ({ page }) => {
+ // Three tabs × light/dark axe is close to the default 30s budget under parallel load.
+ test.setTimeout(60_000);
const base = `/w/${sample().workspaceId}/repositories/${sample().repositoryId}`;
const tabs = page.getByRole('navigation', { name: 'Repository' });
From 5e2001a84b2e2dcf8e42ec5c4bd874b654497508 Mon Sep 17 00:00:00 2001
From: Uniskela <104075208+uniskela@users.noreply.github.com>
Date: Wed, 7 Oct 2026 09:38:05 +0000
Subject: [PATCH 3/5] fix: keep retained push after drafts cleared
Show the Push action when a retained commit exists even with no drafts left, and treat an empty checkbox selection as intentional instead of reselecting every file.
---
src/ui/source/changes-view.tsx | 233 +++++++++++++++++++--------------
1 file changed, 133 insertions(+), 100 deletions(-)
diff --git a/src/ui/source/changes-view.tsx b/src/ui/source/changes-view.tsx
index 6c195d5..e92656a 100644
--- a/src/ui/source/changes-view.tsx
+++ b/src/ui/source/changes-view.tsx
@@ -262,10 +262,11 @@ export function ChangesView(props: {
if (result || error) alertRef.current?.focus();
}, [result, error]);
- // If every previously selected path was discarded, treat the current draft set as selected.
+ // Empty `selected` means the user cleared the selection. Non-empty but all-stale means refresh.
const selectedChanges = useMemo(() => {
const picked = props.changes.filter((c) => selected.has(c.path));
- return picked.length > 0 ? picked : props.changes;
+ if (picked.length > 0 || selected.size === 0) return picked;
+ return props.changes;
}, [props.changes, selected]);
const selectedPaths = useMemo(() => new Set(selectedChanges.map((c) => c.path)), [selectedChanges]);
const clientProblems = useMemo(
@@ -299,10 +300,8 @@ export function ChangesView(props: {
const toggle = (path: string) => {
setSelected((prev) => {
- const base = props.changes.some((c) => prev.has(c.path))
- ? prev
- : new Set(props.changes.map((c) => c.path));
- const next = new Set(base);
+ const hasCurrent = props.changes.some((c) => prev.has(c.path));
+ const next = !hasCurrent && prev.size > 0 ? new Set(props.changes.map((c) => c.path)) : new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
@@ -391,7 +390,7 @@ export function ChangesView(props: {
}
};
- if (props.changes.length === 0) {
+ if (props.changes.length === 0 && !retained) {
return (
Edits you save in the Editor or Docs appear here for review before they are committed.
@@ -407,11 +406,17 @@ export function ChangesView(props: {
return (
-
- {props.changes.length} draft{props.changes.length === 1 ? '' : 's'}
-
- {' · '}
- {selectedChanges.length} selected for commit
+ {props.changes.length === 0 ? (
+
No drafts left
+ ) : (
+ <>
+
+ {props.changes.length} draft{props.changes.length === 1 ? '' : 's'}
+
+ {' · '}
+ {selectedChanges.length} selected for commit
+ >
+ )}
{outdatedSelected.length ? (
<>
{' · '}
@@ -478,99 +483,39 @@ export function ChangesView(props: {
-
Commit
+
{props.changes.length === 0 && retained ? 'Push retained commit' : 'Commit'}
- {selectedChanges.length} file{selectedChanges.length === 1 ? '' : 's'} · branch{' '}
- {props.branch}
- {props.gitIdentity ? (
+ {props.changes.length === 0 && retained ? (
<>
- {' '}
- · author{' '}
-
- {props.gitIdentity.name} <{props.gitIdentity.email}>
-
+ Local commit {shortSha(retained.commitSha)} · branch{' '}
+ {retained.branch}
>
- ) : null}
+ ) : (
+ <>
+ {selectedChanges.length} file{selectedChanges.length === 1 ? '' : 's'} · branch{' '}
+ {props.branch}
+ {props.gitIdentity ? (
+ <>
+ {' '}
+ · author{' '}
+
+ {props.gitIdentity.name} <{props.gitIdentity.email}>
+
+ >
+ ) : null}
+ >
+ )}
-