// @vitest-environment jsdom /** * install-libraries.test.ts * * Tests for the automatic library installation feature introduced when * importing Wokwi ZIP projects: * * UNIT — parseLibrariesTxt() * Parses a libraries.txt string into an array of installable names. * Verifies filtering of comments, blank lines, and @wokwi: entries. * * INTEGRATION — importFromWokwiZip() :: libraries field * Creates real in-memory ZIP files with JSZip, runs the full import * function, and verifies that result.libraries contains the correct names. * * SERVICE — installLibrary() / getInstalledLibraries() * Stubs global fetch and verifies the correct HTTP requests and response * handling of the library service functions. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import JSZip from 'jszip'; import { parseLibrariesTxt, importFromWokwiZip } from '../utils/wokwiZip'; import { installLibrary, getInstalledLibraries } from '../services/libraryService'; // ─── Helpers ────────────────────────────────────────────────────────────────── /** Minimal diagram.json so importFromWokwiZip doesn't throw */ const MINIMAL_DIAGRAM = JSON.stringify({ version: 1, author: 'test', editor: 'wokwi', parts: [{ type: 'wokwi-arduino-uno', id: 'uno', top: 0, left: 0, attrs: {} }], connections: [], }); /** Build an in-memory ZIP File with the given files */ async function makeZip(entries: Record): Promise { const zip = new JSZip(); zip.file('diagram.json', MINIMAL_DIAGRAM); for (const [name, content] of Object.entries(entries)) { zip.file(name, content); } const blob = await zip.generateAsync({ type: 'blob' }); return new File([blob], 'test.zip', { type: 'application/zip' }); } // ─── Real libraries.txt content (from example_zip/extracted/) ───────────────── const PONG_LIBRARIES_TXT = `# Wokwi Library List # See https://docs.wokwi.com/guides/libraries # Automatically added based on includes: Adafruit GFX Library Adafruit SSD1306 `; const CALCULATOR_LIBRARIES_TXT = `Adafruit GFX Library Adafruit FT6206 Library Adafruit ILI9341 LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d LC_baseTools@wokwi:95340986110645c1b45e55597a7caf4d023d4b4a LC_GUIbase@wokwi:cad247b5fc057dce02f20f7dd8902e0ab3464bb0 LC_GUIItems@wokwi:0dabc07df1078c562ee693693d51c280c68131ce LC_GUITextTools@wokwi:0ea40c37c3578be382f9a915362413565a13779f LC_keyboard@wokwi:2f79075f1332b48f679c2ab9f8199344458b2643 LC_RPNCalculator@wokwi:c670627d569e21d822d4bc5e3a04c299191e9dce LC_scrollingList@wokwi:57d9e60a3fbfb0aee4291d3ad9b9bb4ff1ad0650 LCP_breakout@wokwi:93b61107ec6046ae81014971bbab8821411b066f LCP_rpnCalc@wokwi:e87783abb639c397a4c3aa4c4410cd4912407d2c SD Adafruit SSD1351 library `; const SERVO_LIBRARIES_TXT = `Servo `; // ─── 1. parseLibrariesTxt — unit tests ──────────────────────────────────────── describe('parseLibrariesTxt — unit', () => { it('returns empty array for empty content', () => { expect(parseLibrariesTxt('')).toEqual([]); }); it('returns empty array when content is only blank lines', () => { expect(parseLibrariesTxt('\n\n\n')).toEqual([]); }); it('strips comment lines starting with #', () => { const result = parseLibrariesTxt('# comment\n# another\nMyLib\n'); expect(result).toEqual(['MyLib']); }); it('strips blank lines between entries', () => { const result = parseLibrariesTxt('LibA\n\nLibB\n\n\nLibC'); expect(result).toEqual(['LibA', 'LibB', 'LibC']); }); it('includes @wokwi: hash entries (backend handles them)', () => { const result = parseLibrariesTxt( 'GoodLib\nWokwiLib@wokwi:abc123deadbeef\nAnotherGood\n', ); expect(result).toEqual(['GoodLib', 'WokwiLib@wokwi:abc123deadbeef', 'AnotherGood']); }); it('handles inline whitespace (leading/trailing spaces on a line)', () => { const result = parseLibrariesTxt(' Adafruit GFX Library \n \n Servo \n'); expect(result).toEqual(['Adafruit GFX Library', 'Servo']); }); it('parses pong libraries.txt → 2 standard libs', () => { const result = parseLibrariesTxt(PONG_LIBRARIES_TXT); expect(result).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); it('parses calculator-breakout-icon libraries.txt → standard libs AND @wokwi: entries', () => { const result = parseLibrariesTxt(CALCULATOR_LIBRARIES_TXT); expect(result).toContain('Adafruit GFX Library'); expect(result).toContain('Adafruit FT6206 Library'); expect(result).toContain('Adafruit ILI9341'); expect(result).toContain('SD'); expect(result).toContain('Adafruit SSD1351 library'); // Wokwi-hosted entries must also be present expect(result).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d'); expect(result).toContain('LC_baseTools@wokwi:95340986110645c1b45e55597a7caf4d023d4b4a'); // 5 standard + 10 @wokwi: entries visible in CALCULATOR_LIBRARIES_TXT snippet expect(result.length).toBeGreaterThan(5); }); it('parses ServoOverdone libraries.txt → [Servo]', () => { const result = parseLibrariesTxt(SERVO_LIBRARIES_TXT); expect(result).toEqual(['Servo']); }); }); // ─── 2. importFromWokwiZip — libraries field ─────────────────────────────────── describe('importFromWokwiZip — libraries field', () => { it('returns empty array when no libraries.txt is present', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}' }); const result = await importFromWokwiZip(file); expect(result.libraries).toEqual([]); }); it('returns standard libs from a minimal libraries.txt', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': 'Adafruit GFX Library\nAdafruit SSD1306\n', }); const result = await importFromWokwiZip(file); expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); it('includes @wokwi: entries in the returned array (backend installs them)', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': 'GoodLib\nWokwiLib@wokwi:deadbeef12345\n', }); const result = await importFromWokwiZip(file); expect(result.libraries).toEqual(['GoodLib', 'WokwiLib@wokwi:deadbeef12345']); }); it('returns empty array when libraries.txt is only comments', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': '# Wokwi Library List\n# no real libs here\n\n', }); const result = await importFromWokwiZip(file); expect(result.libraries).toEqual([]); }); it('pong ZIP — extracts 2 standard libs', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': PONG_LIBRARIES_TXT, }); const result = await importFromWokwiZip(file); expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']); }); it('calculator ZIP — includes both standard libs AND @wokwi: entries', async () => { const file = await makeZip({ 'sketch.ino': 'void setup(){}void loop(){}', 'libraries.txt': CALCULATOR_LIBRARIES_TXT, }); const result = await importFromWokwiZip(file); // 5 standard Arduino Library Manager libs expect(result.libraries).toContain('Adafruit GFX Library'); expect(result.libraries).toContain('SD'); // Wokwi-hosted libs must also be present expect(result.libraries).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d'); expect(result.libraries.length).toBeGreaterThan(5); }); it('libraries field does not interfere with files[], components[], wires[]', async () => { const file = await makeZip({ 'sketch.ino': '#include \nvoid setup(){}void loop(){}', 'libraries.txt': 'Servo\n', }); const result = await importFromWokwiZip(file); // Board detected expect(result.boardType).toBe('arduino-uno'); // Files parsed expect(result.files.some((f) => f.name === 'sketch.ino')).toBe(true); // Libraries parsed expect(result.libraries).toEqual(['Servo']); }); }); // ─── 3. installLibrary service — HTTP stubs ──────────────────────────────────── describe('installLibrary service', () => { afterEach(() => vi.unstubAllGlobals()); it('POSTs to /api/libraries/install with the library name in the body', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); vi.stubGlobal('fetch', mockFetch); await installLibrary('Adafruit GFX Library'); expect(mockFetch).toHaveBeenCalledOnce(); const [url, opts] = mockFetch.mock.calls[0]; expect(url).toContain('/libraries/install'); expect(opts.method).toBe('POST'); expect(JSON.parse(opts.body)).toEqual({ name: 'Adafruit GFX Library' }); }); it('returns { success: true } when the backend responds 200 ok', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }), })); const result = await installLibrary('Servo'); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); it('returns { success: false, error } when the backend reports failure', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'Library not found' }), })); const result = await installLibrary('NonExistentLib99999'); expect(result.success).toBe(false); expect(result.error).toBe('Library not found'); }); it('installs multiple libraries sequentially without interference', async () => { const calls: string[] = []; vi.stubGlobal('fetch', vi.fn().mockImplementation((_url: string, opts: RequestInit) => { calls.push(JSON.parse(opts.body as string).name); return Promise.resolve({ ok: true, json: async () => ({ success: true }) }); })); const libs = ['Adafruit GFX Library', 'Adafruit SSD1306', 'Servo']; for (const lib of libs) await installLibrary(lib); expect(calls).toEqual(libs); }); }); // ─── 4. getInstalledLibraries service ───────────────────────────────────────── describe('getInstalledLibraries service', () => { afterEach(() => vi.unstubAllGlobals()); it('GETs /api/libraries/list and returns the libraries array', async () => { const mockLibs = [ { library: { name: 'Servo', version: '1.2.1', author: 'Arduino' } }, { library: { name: 'Adafruit GFX Library', version: '1.11.9', author: 'Adafruit' } }, ]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, libraries: mockLibs }), })); const result = await getInstalledLibraries(); expect(result).toHaveLength(2); expect(result[0].library?.name).toBe('Servo'); expect(result[1].library?.name).toBe('Adafruit GFX Library'); }); it('returns empty array when no libraries are installed', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, libraries: [] }), })); const result = await getInstalledLibraries(); expect(result).toEqual([]); }); it('throws when the request fails (non-ok response)', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({ detail: 'Internal server error' }), })); await expect(getInstalledLibraries()).rejects.toThrow('Internal server error'); }); });