ManyfoldTab.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. /**
  2. * The Manyfold tab of Model Sources (#1471): connecting an install, browsing
  3. * and searching its models, importing files, and slicing what was imported.
  4. */
  5. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  6. import { screen, waitFor, within } from '@testing-library/react';
  7. import userEvent from '@testing-library/user-event';
  8. import { http, HttpResponse } from 'msw';
  9. import { render } from '../utils';
  10. import { server } from '../mocks/server';
  11. import { ManyfoldTab } from '../../components/ManyfoldTab';
  12. import { setAuthToken } from '../../api/client';
  13. const model = {
  14. id: 'cube01',
  15. name: 'Calibration Cube',
  16. caption: 'A 20 mm cube',
  17. description: 'Prints in 10 minutes',
  18. license: 'CC-BY-4.0',
  19. tags: ['test', 'cube'],
  20. url: 'http://manyfold.local/models/cube01',
  21. has_preview: true,
  22. files: [
  23. { id: 'f1', name: 'Cube', mime: 'model/stl', importable: true, library_file: null },
  24. { id: 'f2', name: 'Cube Plate', mime: 'model/3mf', importable: true, library_file: { id: 42, filename: 'cube plate.3mf', folder_id: 7 } },
  25. { id: 'f3', name: 'Notes', mime: 'application/pdf', importable: false, library_file: null },
  26. { id: 'f4', name: 'Cube Step', mime: 'model/step', importable: true, library_file: null },
  27. ],
  28. };
  29. function connected(opts: { models?: Array<{ id: string; name: string }>; total?: number; hasNext?: boolean; useSlicerApi?: boolean } = {}) {
  30. const models = opts.models ?? [
  31. { id: 'cube01', name: 'Calibration Cube' },
  32. { id: 'boat02', name: 'Benchy' },
  33. ];
  34. const listRequests: URLSearchParams[] = [];
  35. server.use(
  36. http.get('*/manyfold/status', () => HttpResponse.json({ configured: true, url: 'http://manyfold.local' })),
  37. http.get('*/manyfold/models', ({ request }) => {
  38. const params = new URL(request.url).searchParams;
  39. listRequests.push(params);
  40. const page = Number(params.get('page'));
  41. return HttpResponse.json({
  42. total: opts.total ?? models.length,
  43. page,
  44. has_next: opts.hasNext ?? false,
  45. has_previous: page > 1,
  46. models,
  47. });
  48. }),
  49. http.get('*/manyfold/models/:id/preview', () => new HttpResponse(null, { status: 404 })),
  50. http.get('*/manyfold/models/:id', ({ params }) =>
  51. HttpResponse.json(
  52. params.id === 'cube01'
  53. ? model
  54. : { ...model, id: params.id, name: models.find((m) => m.id === params.id)?.name ?? String(params.id) },
  55. ),
  56. ),
  57. http.get('*/library/folders', () => HttpResponse.json([])),
  58. http.get('*/settings/', () => HttpResponse.json({ use_slicer_api: opts.useSlicerApi ?? true, preferred_slicer: 'bambu_studio' })),
  59. );
  60. return { listRequests };
  61. }
  62. describe('ManyfoldTab', () => {
  63. beforeEach(() => {
  64. // jsdom has no object URLs; previews are blobs turned into one.
  65. URL.createObjectURL = vi.fn(() => 'blob:preview');
  66. URL.revokeObjectURL = vi.fn();
  67. });
  68. afterEach(() => {
  69. vi.restoreAllMocks();
  70. setAuthToken(null);
  71. });
  72. describe('connecting', () => {
  73. beforeEach(() => {
  74. server.use(
  75. http.get('*/manyfold/status', () => HttpResponse.json({ configured: false, url: '' })),
  76. http.get('*/manyfold/config', () =>
  77. HttpResponse.json({ url: '', client_id: '', has_client_secret: false, configured: false }),
  78. ),
  79. );
  80. });
  81. it('tests and saves the connection, then shows the models', async () => {
  82. const user = userEvent.setup();
  83. let saved: Record<string, unknown> | null = null;
  84. let tested: Record<string, unknown> | null = null;
  85. server.use(
  86. http.post('*/manyfold/config/test', async ({ request }) => {
  87. tested = (await request.json()) as Record<string, unknown>;
  88. return HttpResponse.json({ model_count: 12 });
  89. }),
  90. http.put('*/manyfold/config', async ({ request }) => {
  91. saved = (await request.json()) as Record<string, unknown>;
  92. connected();
  93. return HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: true });
  94. }),
  95. );
  96. render(<ManyfoldTab />);
  97. const save = await screen.findByRole('button', { name: 'Save' });
  98. expect(save).toBeDisabled();
  99. await user.type(screen.getByLabelText('Manyfold URL'), 'http://manyfold.local');
  100. await user.type(screen.getByLabelText('Client ID'), 'app');
  101. await user.type(screen.getByLabelText('Client secret'), 's3cret');
  102. await user.click(screen.getByRole('button', { name: 'Test connection' }));
  103. expect(await screen.findByRole('status')).toHaveTextContent('Models Bambuddy can see: 12');
  104. expect(tested).toEqual({ url: 'http://manyfold.local', client_id: 'app', client_secret: 's3cret' });
  105. await user.click(save);
  106. await waitFor(() => expect(saved).toEqual({ url: 'http://manyfold.local', client_id: 'app', client_secret: 's3cret' }));
  107. expect(await screen.findByText('Settings saved')).toBeInTheDocument();
  108. expect(await screen.findByText('Benchy')).toBeInTheDocument();
  109. });
  110. it('shows its own text for a refused secret', async () => {
  111. const user = userEvent.setup();
  112. server.use(
  113. http.post('*/manyfold/config/test', () =>
  114. HttpResponse.json(
  115. { detail: { code: 'manyfold_credentials', message: 'Manyfold did not accept the client ID or secret.' } },
  116. { status: 502 },
  117. ),
  118. ),
  119. );
  120. render(<ManyfoldTab />);
  121. await user.type(await screen.findByLabelText('Manyfold URL'), 'http://manyfold.local');
  122. await user.type(screen.getByLabelText('Client ID'), 'app');
  123. await user.type(screen.getByLabelText('Client secret'), 'wrong');
  124. await user.click(screen.getByRole('button', { name: 'Test connection' }));
  125. expect(await screen.findByRole('status')).toHaveTextContent('Manyfold did not accept the client ID or secret.');
  126. });
  127. it('keeps a stored secret when the field is left empty', async () => {
  128. const user = userEvent.setup();
  129. let saved: Record<string, unknown> | null = null;
  130. server.use(
  131. http.get('*/manyfold/config', () =>
  132. HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: false }),
  133. ),
  134. http.put('*/manyfold/config', async ({ request }) => {
  135. saved = (await request.json()) as Record<string, unknown>;
  136. return HttpResponse.json({ url: 'http://manyfold.local', client_id: 'app', has_client_secret: true, configured: true });
  137. }),
  138. );
  139. render(<ManyfoldTab />);
  140. const secret = await screen.findByPlaceholderText('Stored. Leave empty to keep it.');
  141. expect(secret).toHaveValue('');
  142. await waitFor(() => expect(screen.getByLabelText('Manyfold URL')).toHaveValue('http://manyfold.local'));
  143. await user.click(screen.getByRole('button', { name: 'Save' }));
  144. await waitFor(() => expect(saved).toEqual({ url: 'http://manyfold.local', client_id: 'app' }));
  145. });
  146. });
  147. describe('browsing', () => {
  148. it('lists models, searches, and pages', async () => {
  149. const user = userEvent.setup();
  150. const { listRequests } = connected({ total: 30, hasNext: true });
  151. render(<ManyfoldTab />);
  152. expect(await screen.findByText('Calibration Cube')).toBeInTheDocument();
  153. expect(screen.getByText('Models: 30')).toBeInTheDocument();
  154. await user.type(screen.getByRole('searchbox'), 'bench');
  155. await user.click(screen.getByRole('button', { name: 'Search' }));
  156. await waitFor(() => expect(listRequests.at(-1)?.get('q')).toBe('bench'));
  157. await user.click(screen.getByRole('button', { name: /Next/ }));
  158. await waitFor(() => expect(listRequests.at(-1)?.get('page')).toBe('2'));
  159. expect(listRequests.at(-1)?.get('q')).toBe('bench');
  160. });
  161. it('shows a placeholder for a model without a preview, without an <img>', async () => {
  162. connected();
  163. render(<ManyfoldTab />);
  164. await screen.findByText('Benchy');
  165. // A failing <img> on a protected URL would make the app renew its media token.
  166. await waitFor(() => expect(document.querySelectorAll('img')).toHaveLength(0));
  167. });
  168. it('shows a preview Manyfold has', async () => {
  169. connected();
  170. server.use(
  171. http.get('*/manyfold/models/:id/preview', () =>
  172. new HttpResponse(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'Content-Type': 'image/png' } }),
  173. ),
  174. );
  175. render(<ManyfoldTab />);
  176. await waitFor(() => expect(document.querySelectorAll('img[src="blob:preview"]').length).toBeGreaterThan(0));
  177. });
  178. });
  179. describe('a model', () => {
  180. it('imports a file and offers library actions for imported ones', async () => {
  181. const user = userEvent.setup();
  182. connected();
  183. let imported: Record<string, unknown> | null = null;
  184. server.use(
  185. http.post('*/manyfold/import', async ({ request }) => {
  186. imported = (await request.json()) as Record<string, unknown>;
  187. return HttpResponse.json({ library_file_id: 43, filename: 'cube.stl', folder_id: 7, was_existing: false });
  188. }),
  189. );
  190. render(<ManyfoldTab />);
  191. await user.click(await screen.findByText('Calibration Cube'));
  192. expect(await screen.findByText('A 20 mm cube')).toBeInTheDocument();
  193. expect(screen.getByRole('link', { name: /Open in Manyfold/ })).toHaveAttribute('href', model.url);
  194. const rows = screen.getAllByRole('listitem');
  195. const stl = rows.find((row) => within(row).queryByText('Cube'))!;
  196. const plate = rows.find((row) => within(row).queryByText('Cube Plate'))!;
  197. const notes = rows.find((row) => within(row).queryByText('Notes'))!;
  198. expect(within(plate).getByText('In library')).toBeInTheDocument();
  199. expect(within(plate).getByRole('button', { name: /Slice/ })).toBeInTheDocument();
  200. expect(within(notes).queryByRole('button')).toBeNull();
  201. expect(within(notes).getByText("Bambuddy can't slice or print this type")).toBeInTheDocument();
  202. await user.click(within(stl).getByRole('button', { name: /Import/ }));
  203. await waitFor(() => expect(imported).toEqual({ model_id: 'cube01', file_id: 'f1', folder_id: null }));
  204. expect(await screen.findByText('Imported cube.stl')).toBeInTheDocument();
  205. });
  206. it('hides importing from users without manyfold:import', async () => {
  207. // settings:update shows the Connection button, which proves the user's
  208. // permissions were loaded rather than everything being hidden.
  209. connected();
  210. server.use(
  211. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  212. http.get('*/api/v1/auth/me', () =>
  213. HttpResponse.json({
  214. id: 2,
  215. username: 'viewer',
  216. role: 'user',
  217. is_active: true,
  218. is_admin: false,
  219. groups: [],
  220. permissions: ['manyfold:view', 'settings:update'],
  221. created_at: '2026-01-01T00:00:00Z',
  222. }),
  223. ),
  224. );
  225. setAuthToken('test-token', 'session');
  226. const user = userEvent.setup();
  227. render(<ManyfoldTab />);
  228. expect(await screen.findByRole('button', { name: /Connection/ })).toBeInTheDocument();
  229. await user.click(await screen.findByText('Calibration Cube'));
  230. await screen.findByText('A 20 mm cube');
  231. expect(screen.queryByRole('button', { name: /^Import$/ })).toBeNull();
  232. expect(screen.queryByText('Import to')).toBeNull();
  233. });
  234. });
  235. describe('importing several files', () => {
  236. /** Records each import, and how many ran at the same time. */
  237. function recordImports(fail: string[] = []) {
  238. const calls: Array<Record<string, unknown>> = [];
  239. let running = 0;
  240. let maxRunning = 0;
  241. server.use(
  242. http.post('*/manyfold/import', async ({ request }) => {
  243. const body = (await request.json()) as Record<string, unknown>;
  244. calls.push(body);
  245. running += 1;
  246. maxRunning = Math.max(maxRunning, running);
  247. await new Promise((resolve) => setTimeout(resolve, 20));
  248. running -= 1;
  249. if (fail.includes(String(body.file_id))) {
  250. return HttpResponse.json(
  251. { detail: { code: 'manyfold_unreachable', message: 'down' } },
  252. { status: 502 },
  253. );
  254. }
  255. return HttpResponse.json({ library_file_id: 50, filename: `${body.file_id}.stl`, folder_id: 7, was_existing: false });
  256. }),
  257. );
  258. return { calls, maxRunning: () => maxRunning };
  259. }
  260. it('imports only the ticked files', async () => {
  261. const user = userEvent.setup();
  262. connected();
  263. const rec = recordImports();
  264. render(<ManyfoldTab />);
  265. await user.click(await screen.findByText('Calibration Cube'));
  266. const importSelected = await screen.findByRole('button', { name: /Import selected \(0\)/ });
  267. expect(importSelected).toBeDisabled();
  268. // Imported and unprintable files can't be ticked.
  269. expect(screen.queryByRole('checkbox', { name: 'Select Cube Plate' })).toBeNull();
  270. expect(screen.queryByRole('checkbox', { name: 'Select Notes' })).toBeNull();
  271. await user.click(screen.getByRole('checkbox', { name: 'Select Cube Step' }));
  272. await user.click(screen.getByRole('button', { name: /Import selected \(1\)/ }));
  273. await waitFor(() => expect(rec.calls).toEqual([{ model_id: 'cube01', file_id: 'f4', folder_id: null }]));
  274. expect(await screen.findByText('Imported: 1 · already in library: 0 · failed: 0')).toBeInTheDocument();
  275. });
  276. it('Import all takes every importable file, one at a time, and carries on past a failure', async () => {
  277. const user = userEvent.setup();
  278. connected();
  279. const rec = recordImports(['f1']);
  280. render(<ManyfoldTab />);
  281. await user.click(await screen.findByText('Calibration Cube'));
  282. await user.click(await screen.findByRole('button', { name: 'Import all' }));
  283. await waitFor(() => expect(rec.calls.map((c) => c.file_id)).toEqual(['f1', 'f4']));
  284. expect(rec.maxRunning()).toBe(1);
  285. expect(await screen.findByText('Imported: 1 · already in library: 0 · failed: 1')).toBeInTheDocument();
  286. expect(
  287. screen.getByText("Not imported: Cube. Bambuddy can't reach Manyfold. Check the URL and that Manyfold is running."),
  288. ).toBeInTheDocument();
  289. });
  290. it('Select all ticks every importable file', async () => {
  291. const user = userEvent.setup();
  292. connected();
  293. render(<ManyfoldTab />);
  294. await user.click(await screen.findByText('Calibration Cube'));
  295. await user.click(await screen.findByRole('checkbox', { name: 'Select all' }));
  296. expect(screen.getByRole('button', { name: /Import selected \(2\)/ })).toBeEnabled();
  297. await user.click(screen.getByRole('checkbox', { name: 'Select all' }));
  298. expect(screen.getByRole('button', { name: /Import selected \(0\)/ })).toBeDisabled();
  299. });
  300. it('ticked models in the grid import all their files, and the ticks survive paging', async () => {
  301. const user = userEvent.setup();
  302. connected({ total: 30, hasNext: true });
  303. const rec = recordImports();
  304. render(<ManyfoldTab />);
  305. await user.click(await screen.findByRole('checkbox', { name: 'Select Calibration Cube' }));
  306. expect(screen.getByText('Models selected: 1')).toBeInTheDocument();
  307. await user.click(screen.getByRole('button', { name: /Next/ }));
  308. await waitFor(() => expect(screen.getByText('Page 2')).toBeInTheDocument());
  309. expect(screen.getByText('Models selected: 1')).toBeInTheDocument();
  310. await user.click(screen.getByRole('checkbox', { name: 'Select Benchy' }));
  311. expect(screen.getByText('Models selected: 2')).toBeInTheDocument();
  312. await user.click(screen.getByRole('button', { name: 'Import their files' }));
  313. await waitFor(() => expect(rec.calls).toHaveLength(4));
  314. expect(rec.calls.map((c) => `${c.model_id}/${c.file_id}`)).toEqual([
  315. 'cube01/f1',
  316. 'cube01/f4',
  317. 'boat02/f1',
  318. 'boat02/f4',
  319. ]);
  320. expect(rec.maxRunning()).toBe(1);
  321. expect(await screen.findByText('Imported: 4 · already in library: 0 · failed: 0')).toBeInTheDocument();
  322. await waitFor(() => expect(screen.queryByText(/Models selected/)).toBeNull());
  323. });
  324. it('Select page ticks every model shown', async () => {
  325. const user = userEvent.setup();
  326. connected();
  327. render(<ManyfoldTab />);
  328. await user.click(await screen.findByRole('button', { name: 'Select page' }));
  329. expect(screen.getByText('Models selected: 2')).toBeInTheDocument();
  330. await user.click(screen.getByRole('button', { name: 'Clear selection' }));
  331. expect(screen.queryByText(/Models selected/)).toBeNull();
  332. });
  333. });
  334. });