fix(v5): backfill missing canvas uuid columns

Add guarded migration for existing v5 application, cluster, and resource
connection tables missing uuid columns.

Add Vitest, jsdom, and React Testing Library coverage for optimistic
updates, canvas hooks, ingress, resource merging, viewport behavior, and
v5 browser canvas and cluster flows.
This commit is contained in:
Andras Bacsai
2026-07-06 23:17:32 +02:00
parent fbc6fd5ce5
commit a9501d5476
10 changed files with 2234 additions and 28 deletions
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The uuid columns were added by editing the original v5 create migrations,
* so databases that ran those migrations before the edit (including the
* checked-in testing schema dump) are missing them. Guarded so freshly
* migrated databases are untouched.
*/
return new class extends Migration
{
private const TABLES = ['v5_applications', 'v5_clusters', 'v5_resource_connections'];
public function up(): void
{
foreach (self::TABLES as $tableName) {
if (! Schema::hasTable($tableName) || Schema::hasColumn($tableName, 'uuid')) {
continue;
}
Schema::table($tableName, function (Blueprint $table) {
$table->string('uuid')->nullable()->unique();
});
}
}
public function down(): void
{
// Intentionally left empty: the uuid columns belong to the create
// migrations; dropping them here could destroy live identifiers.
}
};
+1030 -27
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -11,16 +11,19 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^29.1.1",
"laravel-vite-plugin": "3.1.0",
"postcss": "8.5.15",
"shadcn": "^4.11.0",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
"typescript": "^6.0.3",
"vite": "8.0.16"
"vite": "8.0.16",
"vitest": "^4.1.10"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { runOptimisticUpdate } from '@/lib/optimistic';
describe('runOptimisticUpdate', () => {
it('applies, persists, and reconciles on success', async () => {
const calls: string[] = [];
const notify = vi.fn();
const succeeded = await runOptimisticUpdate<string>({
apply: () => calls.push('apply'),
rollback: () => calls.push('rollback'),
request: async () => {
calls.push('request');
return { ok: true, payload: 'server-state' };
},
fallbackErrorMessage: 'fallback',
notify,
onSuccess: (payload) => calls.push(`success:${payload}`),
onSettled: () => calls.push('settled'),
});
expect(succeeded).toBe(true);
expect(calls).toEqual(['apply', 'request', 'success:server-state', 'settled']);
expect(notify).not.toHaveBeenCalled();
});
it('rolls back and notifies with the server error message on failure', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => ({ ok: false, errorMessage: 'Ports must be integers.' }),
fallbackErrorMessage: 'fallback',
notify,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Ports must be integers.');
});
it('falls back to the generic message when the failure carries none', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => ({ ok: false }),
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('rolls back and notifies with the thrown error message', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const onSettled = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => {
throw new Error('Network down.');
},
fallbackErrorMessage: 'fallback',
notify,
onSettled,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Network down.');
expect(onSettled).toHaveBeenCalledOnce();
});
it('uses the fallback message for non-Error throws', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => {
throw 'string failure';
},
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('runs onSettled even when onSuccess throws', async () => {
const onSettled = vi.fn();
await expect(
runOptimisticUpdate({
request: async () => ({ ok: true, payload: undefined }),
fallbackErrorMessage: 'fallback',
notify: vi.fn(),
onSuccess: () => {
throw new Error('reconcile failed');
},
onSettled,
}),
).resolves.toBe(false);
expect(onSettled).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,201 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isValidDomain, useApplicationIngress } from '@/lib/use-application-ingress';
import type { V5Application } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
serverIngressEnabled: true,
ingressEnabled: false,
internalPort: null,
domains: [],
...overrides,
} as V5Application;
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
function renderIngress(notify = vi.fn(), onApplicationUpdated = vi.fn()) {
const rendered = renderHook(() => useApplicationIngress({ notify, onApplicationUpdated }));
return { ...rendered, notify, onApplicationUpdated };
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('isValidDomain', () => {
it.each(['example.com', 'sub.example.com', 'a.io', 'my-app.example.co.uk'])('accepts %s', (domain) => {
expect(isValidDomain(domain)).toBe(true);
});
it.each(['', '.example.com', 'example.com.', '-bad.example.com', 'bad-.example.com', 'exa mple.com', 'UPPER.example.com'])(
'rejects %j',
(domain) => {
expect(isValidDomain(domain)).toBe(false);
},
);
});
describe('toggleApplicationIngress', () => {
it('refuses to open the modal when server ingress is disabled', () => {
const { result, notify } = renderIngress();
act(() => result.current.toggleApplicationIngress(application({ serverIngressEnabled: false })));
expect(notify).toHaveBeenCalledWith('Enable ingress on the server before enabling app ingress.');
expect(result.current.ingressModal).toBeNull();
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('opens the modal prefilled from the application', () => {
const { result } = renderIngress();
act(() =>
result.current.toggleApplicationIngress(application({ domains: ['a.example.com', 'b.example.com'], internalPort: 8080 })),
);
expect(result.current.ingressModal).toMatchObject({
domains: 'a.example.com, b.example.com',
internalPort: '8080',
error: null,
});
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('disables ingress immediately for an enabled application', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ application: application() }));
const enabledApplication = application({ ingressEnabled: true, domains: ['app.example.com'], internalPort: 8080 });
const { result, onApplicationUpdated } = renderIngress();
await act(() => result.current.toggleApplicationIngress(enabledApplication));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: false, internal_port: 8080, domains: ['app.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledOnce();
});
});
describe('submitApplicationIngress', () => {
function openModal(result: { current: ReturnType<typeof useApplicationIngress> }, app = application()): void {
act(() => result.current.toggleApplicationIngress(app));
}
it('requires at least one domain', async () => {
const { result } = renderIngress();
openModal(result);
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Add at least one valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('rejects invalid domains by name', async () => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('good.example.com, bad_domain'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('bad_domain is not a valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it.each(['', '0', '65536', 'abc'])('rejects invalid internal port %j', async (port) => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort(port));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Choose a valid internal port between 1 and 65535.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('persists deduplicated lowercase domains and closes the modal', async () => {
const updatedApplication = application({ ingressEnabled: true });
canvasRequestMock.mockResolvedValue(jsonResponse({ application: updatedApplication }));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('App.Example.com, app.example.com, other.example.com,'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: true, internal_port: 8080, domains: ['app.example.com', 'other.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledWith(updatedApplication);
expect(result.current.ingressModal).toBeNull();
});
it('keeps the modal open and shows the server error on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Domain already in use.' }, false));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Domain already in use.');
expect(onApplicationUpdated).not.toHaveBeenCalled();
});
it('reports network failures inside the modal', async () => {
canvasRequestMock.mockRejectedValue(new Error('Network down.'));
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Network down.');
});
});
describe('modal editing', () => {
it('clears the error when inputs change', async () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).not.toBeNull();
act(() => result.current.setIngressModalDomains('app.example.com'));
expect(result.current.ingressModal?.error).toBeNull();
});
it('closes the modal on demand', () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
act(() => result.current.closeIngressModal());
expect(result.current.ingressModal).toBeNull();
});
});
@@ -0,0 +1,270 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
activeConnectionPorts,
connectionDirectionKey,
pruneConnectionPortsByDirection,
useCanvasConnections,
} from '@/lib/use-canvas-connections';
import type { V5ResourceConnection } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function connection(overrides: Partial<V5ResourceConnection> = {}): V5ResourceConnection {
return {
id: 'connection-1',
applicationIds: ['app-a', 'app-b'],
fromApplicationId: 'app-a',
toApplicationId: 'app-b',
portsByDirection: { 'app-a->app-b': ['80'] },
...overrides,
};
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('connection helpers', () => {
it('builds direction keys and reads active ports', () => {
expect(connectionDirectionKey('app-a', 'app-b')).toBe('app-a->app-b');
expect(activeConnectionPorts(connection())).toEqual(['80']);
expect(activeConnectionPorts(connection({ portsByDirection: {} }))).toEqual([]);
});
it('prunes ports to the active direction only', () => {
const pruned = pruneConnectionPortsByDirection(
connection({ portsByDirection: { 'app-a->app-b': ['80'], 'app-b->app-a': ['443'] } }),
);
expect(pruned).toEqual({ 'app-a->app-b': ['80'] });
});
});
describe('connectionExists', () => {
it('matches connections in both directions', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
expect(result.current.connectionExists('app-a', 'app-b')).toBe(true);
expect(result.current.connectionExists('app-b', 'app-a')).toBe(true);
expect(result.current.connectionExists('app-a', 'app-c')).toBe(false);
});
});
describe('persistNewConnection', () => {
it('adds and selects the persisted connection on success', async () => {
const persisted = connection({ id: 'connection-2', fromApplicationId: 'app-a', toApplicationId: 'app-c' });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-c'));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections', {
method: 'POST',
body: {
resource_one: { type: 'application', uuid: 'app-a' },
resource_two: { type: 'application', uuid: 'app-c' },
},
});
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
expect(result.current.selectedConnectionId).toBe('connection-2');
expect(notify).toHaveBeenCalledWith(null);
expect(notify).not.toHaveBeenCalledWith(expect.stringContaining('Could not'));
});
it('notifies with message and detail on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Connection rejected.', detail: 'Applications overlap.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-b'));
expect(notify).toHaveBeenLastCalledWith('Connection rejected. Applications overlap.');
expect(result.current.connections).toEqual([]);
});
});
describe('addConnectionPort', () => {
it.each(['', '0', '65536', '8.5', 'http'])('ignores invalid port draft %j', (draft) => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', draft));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('ignores a port already present for the active direction', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', '80'));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('optimistically adds the port, persists it, and clears the draft', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': ['80', '443'] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', ' 443 '));
act(() => result.current.addConnectionPort('connection-1'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80', '443']);
expect(result.current.connectionPortInput['connection-1']).toBe('');
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [80, 443] } },
});
});
});
it('rolls the port back when persistence fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Could not save allowed ports.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
act(() => result.current.setConnectionPortDraft('connection-1', '443'));
act(() => result.current.addConnectionPort('connection-1'));
await waitFor(() => {
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80']);
});
expect(notify).toHaveBeenCalledWith('Could not save allowed ports.');
});
});
describe('removeConnectionPort', () => {
it('persists the connection without the removed port', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': [] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.removeConnectionPort('connection-1', '80'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual([]);
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [] } },
});
});
});
});
describe('deleteConnection', () => {
it('optimistically removes the connection and keeps it removed on success', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections).toEqual([]);
expect(result.current.selectedConnectionId).toBeNull();
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { method: 'DELETE' });
});
expect(result.current.connections).toEqual([]);
});
it('restores the connection at its original index when deletion fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}, false));
const first = connection({ id: 'connection-1' });
const second = connection({ id: 'connection-2' });
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([first, second], notify));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
await waitFor(() => {
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
});
expect(notify).toHaveBeenCalledWith('Could not delete resource connection.');
});
it('does nothing for an unknown connection id', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.deleteConnection('missing'));
expect(canvasRequestMock).not.toHaveBeenCalled();
expect(result.current.connections).toHaveLength(1);
});
});
describe('updateConnectionDirection', () => {
it('swaps the active direction locally without a request', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.updateConnectionDirection('connection-1', 'app-b', 'app-a'));
expect(result.current.connections[0]).toMatchObject({ fromApplicationId: 'app-b', toApplicationId: 'app-a' });
expect(canvasRequestMock).not.toHaveBeenCalled();
});
});
describe('removeConnectionsForApplication', () => {
it('drops every connection touching the application', () => {
const related = connection({ id: 'connection-1' });
const unrelated = connection({
id: 'connection-2',
applicationIds: ['app-c', 'app-d'],
fromApplicationId: 'app-c',
toApplicationId: 'app-d',
});
const { result } = renderHook(() => useCanvasConnections([related, unrelated], vi.fn()));
act(() => result.current.removeConnectionsForApplication('app-a'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
});
});
describe('keyboard deletion', () => {
it('deletes the selected connection on Backspace', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toEqual([]);
});
it('ignores Backspace while typing in an input', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
const input = document.createElement('input');
document.body.appendChild(input);
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toHaveLength(1);
input.remove();
});
});
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge';
import type { V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
const channelHandlers: Array<(event: V5CanvasResourceUpdatedEvent) => void> = [];
vi.mock('@/lib/use-canvas-channel', () => ({
useCanvasResourceChannel: (_teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void) => {
channelHandlers.push(onEvent);
},
}));
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
status: 'running',
projectUuid: 'project-1',
environmentUuid: 'env-1',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5Application;
}
function ingress(overrides: Partial<V5CaddyIngress> = {}): V5CaddyIngress {
return {
id: 'ingress-1',
name: 'caddy',
status: 'running',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5CaddyIngress;
}
type HarnessOptions = {
initialApplications?: V5Application[];
initialIngresses?: V5CaddyIngress[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
locallyPositionedApplicationIds?: Set<string>;
locallyPositionedIngressIds?: Set<string>;
};
function renderMergeHarness({
initialApplications = [],
initialIngresses = [],
selectedProjectUuid = 'project-1',
selectedEnvironmentUuid = 'env-1',
locallyPositionedApplicationIds = new Set<string>(),
locallyPositionedIngressIds = new Set<string>(),
}: HarnessOptions = {}) {
let applications = initialApplications;
let ingresses = initialIngresses;
const setApplications = (update: V5Application[] | ((current: V5Application[]) => V5Application[])): void => {
applications = typeof update === 'function' ? update(applications) : update;
};
const setIngresses = (update: V5CaddyIngress[] | ((current: V5CaddyIngress[]) => V5CaddyIngress[])): void => {
ingresses = typeof update === 'function' ? update(ingresses) : update;
};
renderHook(() =>
useCanvasResourceMerge({
teamId: 1,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds: { current: locallyPositionedApplicationIds },
locallyPositionedIngressIds: { current: locallyPositionedIngressIds },
}),
);
const emit = (event: Partial<V5CanvasResourceUpdatedEvent>): void => {
act(() => {
channelHandlers.at(-1)?.({ application: null, caddyIngress: null, ...event });
});
};
return {
emit,
applications: () => applications,
ingresses: () => ingresses,
};
}
beforeEach(() => {
channelHandlers.length = 0;
});
describe('useCanvasResourceMerge', () => {
it('updates an existing application in place', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ status: 'failed', canvasX: 100 }) });
expect(harness.applications()).toEqual([expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 100 })]);
});
it('keeps local canvas position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialApplications: [application({ canvasX: 500, canvasY: 300 })],
locallyPositionedApplicationIds: new Set(['app-1']),
});
harness.emit({ application: application({ status: 'failed', canvasX: 0, canvasY: 0 }) });
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 500, canvasY: 300 }),
]);
});
it('appends unknown applications belonging to the selected project and environment', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-1', environmentUuid: 'env-1' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1', 'app-2']);
});
it('ignores unknown applications from other projects or environments', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-9' }) });
harness.emit({ application: application({ id: 'app-3', environmentUuid: 'env-9' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1']);
});
it('never appends when no project or environment is selected', () => {
const harness = renderMergeHarness({ selectedProjectUuid: null, selectedEnvironmentUuid: null });
harness.emit({ application: application() });
expect(harness.applications()).toEqual([]);
});
it('merges bulk application payloads', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({
applications: [application({ status: 'failed' }), application({ id: 'app-2' })],
});
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed' }),
expect.objectContaining({ id: 'app-2' }),
]);
});
it('updates known ingresses in place but never appends unknown ones', () => {
const harness = renderMergeHarness({ initialIngresses: [ingress()] });
harness.emit({ caddyIngress: ingress({ status: 'stopped' }) });
harness.emit({ caddyIngress: ingress({ id: 'ingress-9' }) });
expect(harness.ingresses()).toEqual([expect.objectContaining({ id: 'ingress-1', status: 'stopped' })]);
});
it('keeps local ingress position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialIngresses: [ingress({ canvasX: 250, canvasY: 100 })],
locallyPositionedIngressIds: new Set(['ingress-1']),
});
harness.emit({ caddyIngress: ingress({ status: 'stopped', canvasX: 0, canvasY: 0 }) });
expect(harness.ingresses()).toEqual([
expect.objectContaining({ id: 'ingress-1', status: 'stopped', canvasX: 250, canvasY: 100 }),
]);
});
});
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { PointerEvent, WheelEvent } from 'react';
import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH } from '@/lib/canvas-geometry';
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, PINCH_CANVAS_ZOOM_STEP, useCanvasViewport } from '@/lib/use-canvas-viewport';
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
function canvasElement(): HTMLDivElement {
const element = document.createElement('div');
element.getBoundingClientRect = () =>
({ left: 0, top: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT, right: CANVAS_WIDTH, bottom: CANVAS_HEIGHT, x: 0, y: 0 }) as DOMRect;
return element;
}
function renderViewport() {
const rendered = renderHook(() => useCanvasViewport());
rendered.result.current.canvasRef.current = canvasElement();
return rendered;
}
describe('zoomCanvas', () => {
it('zooms in by one step around the canvas center', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport.zoom).toBeCloseTo(1.1);
// The canvas point under the viewport center must stay put.
expect((CANVAS_WIDTH / 2 - result.current.viewport.x) / result.current.viewport.zoom).toBeCloseTo(CANVAS_WIDTH / 2);
expect((CANVAS_HEIGHT / 2 - result.current.viewport.y) / result.current.viewport.zoom).toBeCloseTo(CANVAS_HEIGHT / 2);
});
it('keeps an explicit zoom origin stable', () => {
const { result } = renderViewport();
const origin = { x: 100, y: 50 };
act(() => result.current.zoomCanvas(1, 0.5, origin));
const { x, y, zoom } = result.current.viewport;
expect(zoom).toBeCloseTo(1.5);
expect((origin.x - x) / zoom).toBeCloseTo(origin.x);
expect((origin.y - y) / zoom).toBeCloseTo(origin.y);
});
it('clamps zoom to the maximum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1, 10));
expect(result.current.viewport.zoom).toBe(MAX_CANVAS_ZOOM);
});
it('clamps zoom to the minimum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(-1, 10));
expect(result.current.viewport.zoom).toBe(MIN_CANVAS_ZOOM);
});
it('does nothing when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 });
});
});
describe('handleCanvasWheel', () => {
function wheelEvent(overrides: Partial<{ ctrlKey: boolean; deltaY: number; clientX: number; clientY: number }>) {
return {
ctrlKey: true,
deltaY: -1,
clientX: 0,
clientY: 0,
currentTarget: canvasElement(),
preventDefault: () => {},
...overrides,
} as unknown as WheelEvent<HTMLDivElement>;
}
it('pinch-zooms in on ctrl+wheel up', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: -1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 + PINCH_CANVAS_ZOOM_STEP);
});
it('pinch-zooms out on ctrl+wheel down', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: 1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 - PINCH_CANVAS_ZOOM_STEP);
});
it('ignores plain scrolling without ctrl', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ ctrlKey: false })));
expect(result.current.viewport.zoom).toBe(1);
});
});
describe('centerOnCanvasNodes', () => {
it('centers a single card in the canvas', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], []));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - APPLICATION_CARD_WIDTH / 2,
y: CANVAS_HEIGHT / 2 - APPLICATION_CARD_HEIGHT / 2,
zoom: 1,
});
});
it('centers on the midpoint of multiple nodes', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], [{ canvasX: 400, canvasY: 200 }]));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - (200 + APPLICATION_CARD_WIDTH / 2),
y: CANVAS_HEIGHT / 2 - (100 + APPLICATION_CARD_HEIGHT / 2),
zoom: 1,
});
});
it('resets the pan but keeps the zoom when there are no nodes', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
act(() => result.current.centerOnCanvasNodes([], []));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: result.current.viewport.zoom });
expect(result.current.viewport.x).toBe(0);
expect(result.current.viewport.y).toBe(0);
});
});
describe('canvasPointFromPointer', () => {
function pointerEvent(clientX: number, clientY: number): PointerEvent {
return { clientX, clientY } as PointerEvent;
}
it('maps client coordinates through pan and zoom', () => {
const { result } = renderViewport();
act(() => result.current.setViewport({ x: 100, y: 50, zoom: 2 }));
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 100, y: 100 });
});
it('returns the origin when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 0, y: 0 });
});
});
+164
View File
@@ -0,0 +1,164 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\User;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
use App\Models\V5\Server as V5Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
$this->project = Project::create([
'name' => 'Canvas Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->firstOrFail();
});
function createCanvasV5Server(array $attributes = []): V5Server
{
return V5Server::query()->create([
'team_id' => test()->team->id,
'created_by_user_id' => test()->user->id,
'name' => 'edge-01',
'host' => '203.0.113.10',
'ssh_user' => 'root',
'ssh_port' => 22,
'status' => 'installed',
'last_bootstrapped_at' => now(),
...$attributes,
]);
}
function createCanvasV5Application(V5Server $server, array $attributes = []): V5Application
{
return V5Application::query()->create([
'team_id' => test()->team->id,
'project_id' => test()->project->id,
'environment_id' => test()->environment->id,
'server_id' => $server->id,
'created_by_user_id' => test()->user->id,
'name' => 'nginx-test',
'image' => 'docker.io/library/nginx:alpine',
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
'status' => 'running',
'mesh_namespace' => 'default',
'canvas_x' => 0,
'canvas_y' => 0,
...$attributes,
]);
}
it('renders seeded applications on the canvas', function () {
$server = createCanvasV5Server();
createCanvasV5Application($server);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->assertSee('docker.io/library/nginx:alpine')
->assertSee('edge-01')
->assertSee('Configure')
->assertSee('1 apps')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
});
it('shows the selected project and environment in the navbar', function () {
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('Canvas Project')
->assertSee('production')
->assertNoJavaScriptErrors()
->screenshot();
});
it('deploys an nginx container from the toolbar', function () {
createCanvasV5Server();
fakeSuccessfulNginxFluxDeployment();
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('No applications on this canvas yet.')
->click('Deploy')
->assertSee('docker.io/library/nginx:alpine')
->assertDontSee('No applications on this canvas yet.')
->assertNoJavaScriptErrors()
->screenshot();
$application = V5Application::query()->sole();
expect($application->image)->toBe('docker.io/library/nginx:alpine')
->and($application->status)->toBe('running');
});
it('disables app ingress from the inspector sheet', function () {
// Server ingress on, but not "installed", so the Caddy config sync is skipped.
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => true, 'internal_port' => 8080]);
V5ApplicationDomain::query()->create([
'application_id' => $application->id,
'domain' => 'app.example.com',
]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('Configure')
->assertSee('App configuration')
->click('Networking')
->assertSee('Public ingress')
->click('[data-slot="sheet-content"] button:has-text("Disable")')
->assertSee('Private')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeFalse();
});
it('enables app ingress through the ingress dialog', function () {
$server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']);
$application = createCanvasV5Application($server, ['ingress_enabled' => false]);
$this->actingAs($this->user);
$page = visit('/v5');
$page->assertSee('nginx-test')
->click('button:has-text("Enable")')
->assertSee('Enable app ingress')
->fill('[placeholder="example.com, www.example.com"]', 'app.example.com')
->fill('[placeholder="3000"]', '8080')
->click('button:has-text("Enable ingress")')
->assertDontSee('Ingress update failed')
->assertNoJavaScriptErrors()
->screenshot();
expect($application->refresh()->ingress_enabled)->toBeTrue()
->and($application->internal_port)->toBe(8080)
->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']);
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Models\V5\Cluster as V5Cluster;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('broadcasting.default', 'log');
InstanceSettings::create(['id' => 0, 'is_sponsorship_popup_enabled' => false]);
$this->user = User::factory()->create([
'name' => 'Root User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
$this->team = $this->user->teams()->firstOrFail();
});
it('lists existing clusters', function () {
V5Cluster::query()->create([
'team_id' => $this->team->id,
'created_by_user_id' => $this->user->id,
'name' => 'Existing Mesh',
]);
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->assertSee('Existing Mesh')
->assertNoJavaScriptErrors()
->screenshot();
});
it('creates a cluster from the clusters page', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->assertSee('Clusters')
->click('button[aria-label="Create cluster"]')
->assertSee('Create cluster')
->fill('[placeholder="Production Mesh"]', 'Test Mesh')
->click('button:has-text("Create cluster")')
->assertSee('Test Mesh')
->assertNoJavaScriptErrors()
->screenshot();
$cluster = V5Cluster::query()->sole();
expect($cluster->name)->toBe('Test Mesh')
->and($cluster->team_id)->toBe($this->team->id);
});
it('shows a validation error when the cluster name is missing', function () {
$this->actingAs($this->user);
$page = visit('/v5/clusters');
$page->click('button[aria-label="Create cluster"]')
->click('button:has-text("Create cluster")')
->assertSee('The name field is required.')
->screenshot();
expect(V5Cluster::query()->count())->toBe(0);
});