Skip to content

Commit 41a92bb

Browse files
committed
refactor(camera): consolidate streaming to go2rtc backend
Consolidate camera streaming to go2rtc as the primary backend. This refactoring simplifies the camera architecture and enables new features: - Camera snapshot capture via Go2rtcService.captureSnapshot() - Discord webhook notifications can include live camera snapshots - Multipart/form-data webhook payloads when snapshots are enabled - New DiscordIncludeCameraSnapshots config option E2E testing infrastructure for hardware validation: - Guarded /api/e2e/* routes (loopback-only, FFUI_E2E_HARDWARE=1) - Local webhook relay for deterministic assertions - test:e2e:hardware script with env var validation Includes import ordering and formatting cleanup across several files.
1 parent 47f37e9 commit 41a92bb

23 files changed

+1154
-82
lines changed

e2e-emulator/helpers/standalone-server-harness.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface StartStandaloneServerOptions {
5555
authRequired?: boolean;
5656
password?: string;
5757
seededPrinters?: readonly SeededPrinterDetailsEntry[];
58+
configOverrides?: Partial<AppConfig>;
5859
startupTimeoutMs?: number;
5960
}
6061

@@ -306,14 +307,19 @@ const buildConfigPayload = (params: {
306307
webUiPort: number;
307308
authRequired: boolean;
308309
password: string;
310+
configOverrides?: Partial<AppConfig>;
309311
}): AppConfig => {
310312
return {
311313
...DEFAULT_CONFIG,
314+
...(params.configOverrides ?? {}),
312315
WebUIEnabled: true,
313316
WebUIPort: params.webUiPort,
314317
WebUIPassword: params.password,
315318
WebUIPasswordRequired: params.authRequired,
316-
WebUITheme: { ...DEFAULT_CONFIG.WebUITheme },
319+
WebUITheme: {
320+
...DEFAULT_CONFIG.WebUITheme,
321+
...(params.configOverrides?.WebUITheme ?? {}),
322+
},
317323
};
318324
};
319325

@@ -322,6 +328,7 @@ const writeSeededConfig = async (params: {
322328
webUiPort: number;
323329
authRequired: boolean;
324330
password: string;
331+
configOverrides?: Partial<AppConfig>;
325332
}): Promise<void> => {
326333
const configPath = path.join(params.dataDir, 'config.json');
327334
const payload = buildConfigPayload(params);
@@ -379,6 +386,7 @@ export const startStandaloneServer = async (
379386
webUiPort,
380387
authRequired,
381388
password,
389+
configOverrides: options.configOverrides,
382390
});
383391
await writeSeededPrinters(dataDir, options.seededPrinters ?? []);
384392

e2e/discord-hardware.spec.ts

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/**
2+
* @fileoverview Live standalone WebUI hardware coverage for Discord webhook payloads with camera snapshots.
3+
*/
4+
5+
import { expect, test } from '@playwright/test';
6+
import { startStandaloneServer } from '../e2e-emulator/helpers/standalone-server-harness';
7+
import { StandaloneWebUiPage } from '../e2e-emulator/helpers/webui-page';
8+
import {
9+
type CapturedDiscordWebhookRequest,
10+
startDiscordWebhookRelay,
11+
} from './helpers/discord-webhook-relay';
12+
13+
const HARDWARE_FLAG = 'FFUI_E2E_HARDWARE';
14+
const DEFAULT_TIMEOUT_MS = 180_000;
15+
const FORWARD_URL = process.env.FFUI_E2E_DISCORD_FORWARD_URL?.trim();
16+
const PRINTER_NAME = process.env.FFUI_E2E_AD5X_NAME?.trim() || 'AD5X';
17+
18+
type ContextSummary = {
19+
id: string;
20+
name: string;
21+
};
22+
23+
type PrinterStatusPayload = {
24+
status: {
25+
printerState: string;
26+
bedTemperature: number;
27+
bedTargetTemperature: number;
28+
nozzleTemperature: number;
29+
nozzleTargetTemperature: number;
30+
};
31+
};
32+
33+
type TemperatureField = {
34+
current: number;
35+
target: number;
36+
};
37+
38+
async function sleep(ms: number): Promise<void> {
39+
await new Promise((resolve) => setTimeout(resolve, ms));
40+
}
41+
42+
function requireEnv(name: string): string {
43+
const value = process.env[name]?.trim();
44+
if (!value) {
45+
throw new Error(`Missing required environment variable: ${name}`);
46+
}
47+
48+
return value;
49+
}
50+
51+
async function getActiveContext(baseUrl: string): Promise<ContextSummary> {
52+
const response = await fetch(`${baseUrl}/api/contexts`);
53+
const payload = (await response.json()) as {
54+
success: boolean;
55+
activeContextId?: string | null;
56+
contexts?: Array<{ id: string; name: string; isActive: boolean }>;
57+
};
58+
59+
const activeContext =
60+
payload.contexts?.find((context) => context.id === payload.activeContextId) ??
61+
payload.contexts?.find((context) => context.isActive);
62+
63+
if (!payload.success || !activeContext) {
64+
throw new Error('Unable to determine active standalone context');
65+
}
66+
67+
return {
68+
id: activeContext.id,
69+
name: activeContext.name,
70+
};
71+
}
72+
73+
async function waitForCameraReady(baseUrl: string, contextId: string): Promise<void> {
74+
await expect
75+
.poll(
76+
async () => {
77+
const response = await fetch(
78+
`${baseUrl}/api/camera/proxy-config?contextId=${encodeURIComponent(contextId)}`
79+
);
80+
if (!response.ok) {
81+
return false;
82+
}
83+
84+
const payload = (await response.json()) as {
85+
success?: boolean;
86+
streamName?: string;
87+
};
88+
89+
return payload.success === true && typeof payload.streamName === 'string';
90+
},
91+
{
92+
timeout: 45_000,
93+
}
94+
)
95+
.toBe(true);
96+
97+
await sleep(1_000);
98+
}
99+
100+
async function getPrinterStatus(
101+
baseUrl: string,
102+
contextId: string
103+
): Promise<PrinterStatusPayload['status']> {
104+
const response = await fetch(
105+
`${baseUrl}/api/printer/status?contextId=${encodeURIComponent(contextId)}`
106+
);
107+
const payload = (await response.json()) as PrinterStatusPayload & { success: boolean };
108+
109+
if (!response.ok || !payload.success) {
110+
throw new Error('Unable to retrieve standalone printer status');
111+
}
112+
113+
return payload.status;
114+
}
115+
116+
async function triggerDiscordRoute(
117+
baseUrl: string,
118+
path: string,
119+
body: Record<string, unknown>,
120+
timeoutMs: number = 30_000
121+
): Promise<void> {
122+
const deadline = Date.now() + timeoutMs;
123+
let lastFailure = 'No response received';
124+
125+
while (Date.now() < deadline) {
126+
const response = await fetch(`${baseUrl}${path}`, {
127+
method: 'POST',
128+
headers: {
129+
'Content-Type': 'application/json',
130+
},
131+
body: JSON.stringify(body),
132+
});
133+
134+
const responseText = await response.text();
135+
if (response.ok) {
136+
return;
137+
}
138+
139+
lastFailure = `${response.status} ${response.statusText}: ${responseText}`;
140+
await sleep(500);
141+
}
142+
143+
throw new Error(`Discord E2E route ${path} did not become ready: ${lastFailure}`);
144+
}
145+
146+
function getFirstEmbed(request: CapturedDiscordWebhookRequest): Record<string, unknown> {
147+
const embeds = request.payload.embeds;
148+
if (!Array.isArray(embeds) || embeds.length === 0) {
149+
throw new Error('Webhook payload does not include embeds');
150+
}
151+
152+
const embed = embeds[0];
153+
if (!embed || typeof embed !== 'object') {
154+
throw new Error('First embed is not an object');
155+
}
156+
157+
return embed as Record<string, unknown>;
158+
}
159+
160+
function getEmbedFieldMap(embed: Record<string, unknown>): Map<string, string> {
161+
const fields = embed.fields;
162+
if (!Array.isArray(fields)) {
163+
throw new Error('Embed fields are missing');
164+
}
165+
166+
const map = new Map<string, string>();
167+
for (const field of fields) {
168+
if (!field || typeof field !== 'object') {
169+
continue;
170+
}
171+
172+
const name = typeof field.name === 'string' ? field.name : null;
173+
const value = typeof field.value === 'string' ? field.value : null;
174+
if (name && value) {
175+
map.set(name, value);
176+
}
177+
}
178+
179+
return map;
180+
}
181+
182+
function assertMultipartSnapshotRequest(request: CapturedDiscordWebhookRequest): void {
183+
expect(request.contentType.toLowerCase()).toContain('multipart/form-data');
184+
expect(request.attachment).not.toBeNull();
185+
expect(request.attachment?.contentType.toLowerCase()).toContain('image/');
186+
expect(request.attachment?.bytes.byteLength ?? 0).toBeGreaterThan(0);
187+
188+
const embed = getFirstEmbed(request);
189+
const image = embed.image;
190+
expect(image).toEqual(
191+
expect.objectContaining({
192+
url: `attachment://${request.attachment?.filename}`,
193+
})
194+
);
195+
}
196+
197+
function normalizeStatusValue(value: string | undefined): string {
198+
return value?.trim().toLowerCase() ?? '';
199+
}
200+
201+
function parseTemperatureField(value: string | undefined, fieldName: string): TemperatureField {
202+
const match = value?.match(/^(-?\d+(?:\.\d+)?)C \/ (-?\d+(?:\.\d+)?)C$/);
203+
if (!match) {
204+
throw new Error(`Unexpected ${fieldName} field value: ${String(value)}`);
205+
}
206+
207+
return {
208+
current: Number(match[1]),
209+
target: Number(match[2]),
210+
};
211+
}
212+
213+
function expectTemperatureFieldNear(
214+
value: string | undefined,
215+
expectedCurrent: number,
216+
expectedTarget: number,
217+
fieldName: string
218+
): void {
219+
const actual = parseTemperatureField(value, fieldName);
220+
expect(Math.abs(actual.current - expectedCurrent)).toBeLessThanOrEqual(0.5);
221+
expect(Math.abs(actual.target - expectedTarget)).toBeLessThanOrEqual(0.01);
222+
}
223+
224+
test.describe('standalone hardware discord relay', () => {
225+
test.skip(
226+
!process.env[HARDWARE_FLAG],
227+
`Set ${HARDWARE_FLAG}=1 to run live standalone hardware Discord tests`
228+
);
229+
230+
test('connects to the real AD5X and sends Discord status + print-complete payloads with snapshots', async ({
231+
page,
232+
}) => {
233+
test.setTimeout(DEFAULT_TIMEOUT_MS);
234+
const printerIp = requireEnv('FFUI_E2E_AD5X_IP');
235+
const printerCheckCode = requireEnv('FFUI_E2E_AD5X_CHECK_CODE');
236+
237+
const relay = await startDiscordWebhookRelay({
238+
forwardUrl: FORWARD_URL,
239+
});
240+
const server = await startStandaloneServer({
241+
configOverrides: {
242+
DiscordSync: true,
243+
DiscordIncludeCameraSnapshots: true,
244+
DiscordUpdateIntervalMinutes: 60,
245+
WebhookUrl: relay.webhookUrl,
246+
},
247+
});
248+
249+
try {
250+
const webUi = new StandaloneWebUiPage(page);
251+
await webUi.goto(server.baseUrl);
252+
await webUi.connectDirect({
253+
ipAddress: printerIp,
254+
printerType: 'new',
255+
checkCode: printerCheckCode,
256+
expectedPrinterName: PRINTER_NAME,
257+
});
258+
259+
const context = await getActiveContext(server.baseUrl);
260+
await waitForCameraReady(server.baseUrl, context.id);
261+
webUi.clearUnexpectedErrors();
262+
const printerStatus = await getPrinterStatus(server.baseUrl, context.id);
263+
264+
relay.reset();
265+
await triggerDiscordRoute(server.baseUrl, '/api/e2e/discord/send-current-status', {
266+
contextId: context.id,
267+
});
268+
269+
const statusRequest = await relay.waitForRequest({
270+
timeoutMs: 30_000,
271+
});
272+
273+
assertMultipartSnapshotRequest(statusRequest);
274+
const statusEmbed = getFirstEmbed(statusRequest);
275+
const statusFields = getEmbedFieldMap(statusEmbed);
276+
expect(String(statusEmbed.title ?? '')).toContain(PRINTER_NAME);
277+
expect(String(statusEmbed.title ?? '')).toContain(context.name);
278+
expect(normalizeStatusValue(statusFields.get('Status'))).toContain(
279+
normalizeStatusValue(printerStatus.printerState)
280+
);
281+
expectTemperatureFieldNear(
282+
statusFields.get('Bed Temp'),
283+
printerStatus.bedTemperature,
284+
printerStatus.bedTargetTemperature,
285+
'Bed Temp'
286+
);
287+
expectTemperatureFieldNear(
288+
statusFields.get('Extruder Temp'),
289+
printerStatus.nozzleTemperature,
290+
printerStatus.nozzleTargetTemperature,
291+
'Extruder Temp'
292+
);
293+
294+
relay.reset();
295+
await triggerDiscordRoute(server.baseUrl, '/api/e2e/discord/send-print-complete', {
296+
contextId: context.id,
297+
fileName: 'e2e-ad5x-validation.3mf',
298+
durationSeconds: 3661,
299+
});
300+
301+
const printCompleteRequest = await relay.waitForRequest({
302+
timeoutMs: 30_000,
303+
});
304+
305+
assertMultipartSnapshotRequest(printCompleteRequest);
306+
const printCompleteFields = getEmbedFieldMap(getFirstEmbed(printCompleteRequest));
307+
expect(printCompleteFields.get('File')).toBe('e2e-ad5x-validation.3mf');
308+
expect(printCompleteFields.get('Total Time')).toBe('1h 1m');
309+
310+
webUi.assertNoUnexpectedErrors();
311+
} finally {
312+
await server.stop().catch(() => undefined);
313+
await relay.close().catch(() => undefined);
314+
}
315+
});
316+
});

0 commit comments

Comments
 (0)