Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState, type AnchorHTMLAttributes } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { checkAccessibility } from '@/tests/utils/a11y';
import { render, screen, waitFor, within } from '@/tests/utils/render';
import { act, render, screen, waitFor, within } from '@/tests/utils/render';

import { AutomationVersionPicker } from './automation-version-picker';
import { AutomationVersionPickerTarget } from './automation-version-picker-target';
Expand Down Expand Up @@ -296,7 +296,10 @@ async function editTheNode(user: ReturnType<typeof renderPage>['user']) {
}

beforeEach(() => {
saveMutation.mutateAsync = vi.fn().mockResolvedValue(undefined);
saveMutation.mutateAsync = vi.fn().mockResolvedValue({
name: 'billing/dunning',
version: 4,
});
saveMutation.isPending = false;
toastSpy.mockClear();
onSelectVersion.mockClear();
Expand Down Expand Up @@ -1010,7 +1013,7 @@ describe('AutomationEditor', () => {
baseVersion: 3,
},
})
.mockResolvedValue(undefined);
.mockResolvedValue({ name: 'billing/dunning', version: 5 });
const { user } = renderPage();
await editTheNode(user);
await user.click(saveButton());
Expand Down Expand Up @@ -1042,6 +1045,208 @@ describe('AutomationEditor', () => {
});
});

it.each([false, true])(
'keeps later edits dirty after Save anyway succeeds (query refreshed: %s)',
async (refreshQuery) => {
const append = Promise.withResolvers<{ name: string; version: number }>();
saveMutation.mutateAsync = vi
.fn()
.mockRejectedValueOnce({
data: {
code: 'AUTOMATION_VERSION_STALE',
message: 'v4 landed.',
latestVersion: 4,
baseVersion: 3,
},
})
.mockImplementationOnce(() => append.promise)
.mockResolvedValue({ name: 'billing/dunning', version: 6 });
const { user, rerender } = renderPage();
await user.click(screen.getByRole('button', { name: 'select summary' }));
const prompt = () => screen.getByRole('textbox', { name: 'Prompt' });
await user.clear(prompt());
await user.paste('Submitted draft');
await user.click(saveButton());
await user.click(screen.getByRole('button', { name: 'Save version' }));
await screen.findByText('v4 landed.');
await user.click(screen.getByRole('button', { name: 'Save anyway' }));

saveMutation.isPending = true;
rerender(page());
expect(screen.queryByRole('dialog')).toBeNull();
expect(prompt()).not.toHaveAttribute('readonly');
expect(saveButton()).toBeDisabled();
expect(discardButton()).toBeDisabled();
expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
baseVersion: 4,
automation: expect.objectContaining({
nodes: [expect.objectContaining({ prompt: 'Submitted draft' })],
}),
}),
);

await user.clear(prompt());
await user.paste('Later unsaved draft');
expect(prompt()).toHaveValue('Later unsaved draft');
const submitted = saveMutation.mutateAsync.mock.calls[1]?.[0].automation;
expect(submitted.nodes[0].prompt).toBe('Submitted draft');
if (refreshQuery) {
state.document = submitted;
state.version = 5;
rerender(page());
}
await act(async () => {
saveMutation.isPending = false;
append.resolve({ name: 'billing/dunning', version: 5 });
});
rerender(page());

expect(prompt()).toHaveValue('Later unsaved draft');
expect(saveButton()).toBeEnabled();
expect(discardButton()).toBeEnabled();
expect(toastSpy).not.toHaveBeenCalled();
expect(onSelectVersion).toHaveBeenCalledWith(undefined);

await user.click(saveButton());
await user.click(screen.getByRole('button', { name: 'Save version' }));
await waitFor(() => expect(discardButton()).toBeDisabled());
expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
baseVersion: 5,
automation: expect.objectContaining({
nodes: [expect.objectContaining({ prompt: 'Later unsaved draft' })],
}),
}),
);
},
30_000,
);

it.each([true, false])(
'settles only its own draft after a pending append is discarded (replacement edited while pending: %s)',
async (editWhilePending) => {
state.deployedDocument = {
name: 'billing/dunning',
description: 'The v2 document.',
nodes: [{ id: 'summary', type: 'llm', prompt: 'Stored on v2' }],
};
const append = Promise.withResolvers<{ name: string; version: number }>();
saveMutation.mutateAsync = vi
.fn()
.mockRejectedValueOnce({
data: {
code: 'AUTOMATION_VERSION_STALE',
message: 'v4 landed.',
latestVersion: 4,
baseVersion: 3,
},
})
.mockImplementationOnce(() => append.promise)
.mockRejectedValueOnce({
data: {
code: 'AUTOMATION_VERSION_STALE',
message: 'v5 landed after v2.',
latestVersion: 5,
baseVersion: 2,
},
});
const { user, rerender } = renderPage();
const prompt = () => screen.getByRole('textbox', { name: 'Prompt' });
const editReplacement = async () => {
await user.clear(prompt());
await user.paste('Edited on v2');
};
await user.click(screen.getByRole('button', { name: 'select summary' }));
await user.clear(prompt());
await user.paste('Submitted draft');
await user.click(saveButton());
await user.click(screen.getByRole('button', { name: 'Save version' }));
await screen.findByText('v4 landed.');
await user.click(screen.getByRole('button', { name: 'Save anyway' }));
saveMutation.isPending = true;
rerender(page());

expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
baseVersion: 4,
automation: expect.objectContaining({
nodes: [expect.objectContaining({ prompt: 'Submitted draft' })],
}),
}),
);
const submitted = saveMutation.mutateAsync.mock.calls[1]?.[0].automation;
await user.click(versionPicker());
await user.click(screen.getByRole('radio', { name: /^v2/ }));
await user.click(
screen.getByRole('button', { name: 'Discard and switch' }),
);
expect(prompt()).toHaveValue('Stored on v2');
if (editWhilePending) await editReplacement();
expect(versionPicker()).toHaveTextContent('v2');
onSelectVersion.mockClear();

state.document = submitted;
state.version = 5;
rerender(page());
await act(async () => {
saveMutation.isPending = false;
append.resolve({ name: 'billing/dunning', version: 5 });
});
rerender(page());

expect(versionPicker()).toHaveTextContent('v2');
expect(onSelectVersion).not.toHaveBeenCalled();
expect(prompt()).toHaveValue(
editWhilePending ? 'Edited on v2' : 'Stored on v2',
);
expect(submitted.nodes[0].prompt).toBe('Submitted draft');
if (!editWhilePending) {
expect(discardButton()).toBeDisabled();
await editReplacement();
}
expect(saveButton()).toBeEnabled();
expect(discardButton()).toBeEnabled();
await user.click(saveButton());
await user.click(screen.getByRole('button', { name: 'Save version' }));
await screen.findByText('v5 landed after v2.');
expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
baseVersion: 2,
automation: expect.objectContaining({
description: 'The v2 document.',
nodes: [expect.objectContaining({ prompt: 'Edited on v2' })],
}),
}),
);
expect(toastSpy).not.toHaveBeenCalled();
},
30_000,
);

it('keeps the draft when the stale-version decision is cancelled', async () => {
saveMutation.mutateAsync = vi.fn().mockRejectedValue({
data: {
code: 'AUTOMATION_VERSION_STALE',
message: 'v4 landed.',
latestVersion: 4,
baseVersion: 3,
},
});
const { user } = renderPage();
await editTheNode(user);
await user.click(saveButton());
await user.click(screen.getByRole('button', { name: 'Save version' }));
await screen.findByText('v4 landed.');
await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(whenField()).toHaveValue('x');
expect(saveButton()).toBeEnabled();
expect(discardButton()).toBeEnabled();
expect(saveMutation.mutateAsync).toHaveBeenCalledTimes(1);
expect(toastSpy).not.toHaveBeenCalled();
});

it('drops the draft and shows the newer version on reload after a stale refusal', async () => {
saveMutation.mutateAsync = vi.fn().mockRejectedValue({
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ function AutomationEditorScope({
/** The version the draft was built on — pinned on its first edit, sent
* with the save so the store can refuse a draft another tab overtook. */
const draftBaseRef = useRef<number | undefined>(undefined);
const draftEpochRef = useRef(0);
/** A save the store refused because a version landed after the draft
* started: the author decides — drop the draft and reload, or save on
* top of what landed. Never resolved silently either way. */
Expand Down Expand Up @@ -357,7 +358,10 @@ function AutomationEditorScope({
// follows every version another tab saves (its hint invalidates the
// read), so reading the version at save time would name the one that
// overtook the draft, not the one it was built on.
if (draft === null) draftBaseRef.current = automationQuery.data?.version;
if (draft === null) {
draftBaseRef.current = automationQuery.data?.version;
draftEpochRef.current += 1;
}
setDraft(patchNode(automation, selectedNodeId, patch));
},
[automation, selectedNodeId, draft, automationQuery.data?.version],
Expand Down Expand Up @@ -407,6 +411,7 @@ function AutomationEditorScope({
}, []);

const discardDraft = useCallback(() => {
draftEpochRef.current += 1;
setDraft(null);
}, []);

Expand Down Expand Up @@ -532,7 +537,8 @@ function AutomationEditorScope({
/** Append the draft as a version built on `baseVersion` (none: append
* whatever the latest is), then show the version that landed. */
const submitSave = async (baseVersion: number | undefined): Promise<void> => {
await save.mutateAsync({
const submittedEpoch = draftEpochRef.current;
const saved = await save.mutateAsync({
organizationId,
automation,
// Package metadata belongs to the version being edited, even when
Expand All @@ -553,8 +559,10 @@ function AutomationEditorScope({
...(projectId !== undefined && { projectId }),
...(baseVersion !== undefined && { baseVersion }),
});
if (draftEpochRef.current !== submittedEpoch) return;
setSaveDialogOpen(false);
setDraft(null);
draftBaseRef.current = saved.version;
setDraft((current) => (current === automation ? null : current));
setSaveMessage('');
// The save appended a version; show it, whichever one was on screen.
onSelectVersion(undefined);
Expand Down Expand Up @@ -602,7 +610,7 @@ function AutomationEditorScope({
/** Drop the draft and show the version that landed. */
const reloadAfterStale = (): void => {
setStaleSave(null);
setDraft(null);
discardDraft();
onSelectVersion(undefined);
};

Expand Down Expand Up @@ -1002,7 +1010,7 @@ function AutomationEditorScope({
confirmText={t('detail.switchVersion.confirm')}
variant="destructive"
onConfirm={() => {
setDraft(null);
discardDraft();
if (pendingVersion !== null) onSelectVersion(pendingVersion);
setPendingVersion(null);
}}
Expand Down
1 change: 1 addition & 0 deletions services/platform/tests/manual/reference/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ Legend: ✅ fully automated · 🔶 partially automated · ⛔ manual-only (no s
| [automations](../suites/automations.md) | `AUTO-F39` (the General tab): Trigger and Projects as sections under one Save/Discard cluster; Save writes every edited section, Discard restores both, a refused save raises one toast and keeps the edit, a refetch that only moves the last fire or answers the same project set keeps an edit in progress, a removed trigger leaves no unsaved edit behind; members see no cluster | 🔶 component | `automation-general-tab.test.tsx`, `trigger-editor.test.tsx`, `project-bindings-section.test.tsx`, `automation-detail-shell.test.tsx` (tab order, the General tab's dot); the unsaved-changes dialog on leaving stays manual |
| [automations](../suites/automations.md) | Everything else in `AUTO-F1`–`AUTO-F34`, `AUTO-B1`–`AUTO-B6` | ⛔ manual-only | — |
| [automations](../suites/automations.md) | `AUTO-F5`, `AUTO-F13`–`AUTO-F16`, `AUTO-F19`, `AUTO-F35`, `AUTO-F36` | 🔶 partial | unit: `automations-list.test.tsx`, `automation-detail-shell.test.tsx`, `automation-editor.test.tsx`, `automation-canvas.test.tsx`, `node-inspector.test.tsx`, `version-list.test.tsx`, `detail-paths.test.ts` |
| [automations](../suites/automations.md) | Save anyway keeps edits made during a pending append as a dirty draft; the submitted snapshot is unchanged, and the next save uses the appended version as its base even before the detail query refreshes. Cancelling a stale-version decision retains the draft; a save without later edits still clears it. | 🔶 component | `app/features/automations/components/automation-editor.test.tsx` (real editor, inspector and shared controls; deferred data-hook response, canvas fixture); live two-session/network timing remains manual |
| [automations](../suites/automations.md) | `AUTO-F18`, `AUTO-F20` (manual run input) | 🔶 partial | `automation-editor.test.tsx`: required JSON input, malformed/type-invalid refusal, displayed mock version, deployed live schema/version and explicit confirmation; real connector effects stay manual |
| [automations](../suites/automations.md) | `AUTO-F8`–`AUTO-F11` | 🔶 partial | unit: `upload-automation-dialog.test.tsx` (lanes, zip cap, skill-conflict panel — no end-to-end run) |
| [automations](../suites/automations.md) | `AUTO-F22`–`AUTO-F23`, `AUTO-F26` | 🔶 partial | unit: `effect-list.test.tsx`, `agent-execution-log.test.tsx`, `run-ask-card.test.tsx` |
Expand Down
Loading