|
1 | 1 | /** |
2 | 2 | * @fileoverview Tests for FlashForgeTcpClient file list parsing logic, validating extraction |
3 | | - * of filenames from M661 command responses across different printer models. |
| 3 | + * of filenames from M661 command responses across different printer models and |
| 4 | + * the shared legacy upload workflow used by Adventurer 3/4-style printers. |
4 | 5 | */ |
| 6 | +import * as fs from 'node:fs'; |
| 7 | +import type * as net from 'node:net'; |
| 8 | +import * as os from 'node:os'; |
| 9 | +import * as path from 'node:path'; |
| 10 | +import { EventEmitter } from 'node:events'; |
5 | 11 | import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; |
| 12 | +import { GCodes } from './client/GCodes'; |
6 | 13 | import { FlashForgeTcpClient } from './FlashForgeTcpClient'; |
7 | 14 |
|
8 | 15 | // Suppress logs (from API files) during tests |
@@ -34,6 +41,61 @@ const parseFileListResponse = (response: string): string[] => { |
34 | 41 | return result; |
35 | 42 | }; |
36 | 43 |
|
| 44 | +const createUploadTestClient = ( |
| 45 | + commandResponses: Record<string, string | null> |
| 46 | +): { |
| 47 | + client: FlashForgeTcpClient; |
| 48 | + writes: Array<string | Buffer>; |
| 49 | +} => { |
| 50 | + const writes: Array<string | Buffer> = []; |
| 51 | + const mockSocket = new EventEmitter() as net.Socket; |
| 52 | + |
| 53 | + Object.assign(mockSocket, { |
| 54 | + destroyed: false, |
| 55 | + setTimeout: vi.fn(), |
| 56 | + destroy: vi.fn(() => { |
| 57 | + Object.assign(mockSocket, { destroyed: true }); |
| 58 | + }), |
| 59 | + write: vi.fn( |
| 60 | + ( |
| 61 | + data: string | Buffer, |
| 62 | + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), |
| 63 | + callbackMaybe?: (error?: Error | null) => void |
| 64 | + ) => { |
| 65 | + const callback = |
| 66 | + typeof encodingOrCallback === 'function' ? encodingOrCallback : callbackMaybe; |
| 67 | + writes.push(Buffer.isBuffer(data) ? Buffer.from(data) : data); |
| 68 | + callback?.(null); |
| 69 | + |
| 70 | + if (!Buffer.isBuffer(data)) { |
| 71 | + const response = commandResponses[data]; |
| 72 | + if (response !== undefined && response !== null) { |
| 73 | + setTimeout(() => { |
| 74 | + mockSocket.emit('data', Buffer.from(response, 'utf8')); |
| 75 | + }, 0); |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + return true; |
| 80 | + } |
| 81 | + ), |
| 82 | + }); |
| 83 | + |
| 84 | + // @ts-expect-error access private connect for tests |
| 85 | + const originalConnect = FlashForgeTcpClient.prototype.connect; |
| 86 | + // @ts-expect-error inject a mocked socket instead of opening a real TCP connection |
| 87 | + FlashForgeTcpClient.prototype.connect = function mockConnect(): void { |
| 88 | + this.socket = mockSocket; |
| 89 | + }; |
| 90 | + |
| 91 | + const client = new FlashForgeTcpClient('localhost'); |
| 92 | + |
| 93 | + // @ts-expect-error restore private method after client creation |
| 94 | + FlashForgeTcpClient.prototype.connect = originalConnect; |
| 95 | + |
| 96 | + return { client, writes }; |
| 97 | +}; |
| 98 | + |
37 | 99 | describe('FlashForgeTcpClient', () => { |
38 | 100 | // Mock socket methods |
39 | 101 | beforeAll(() => { |
@@ -126,4 +188,71 @@ describe('FlashForgeTcpClient', () => { |
126 | 188 | expect(result.length).toBe(15); |
127 | 189 | }); |
128 | 190 | }); |
| 191 | + |
| 192 | + describe('uploadFile', () => { |
| 193 | + it('should upload a legacy file using M28, raw binary data, and M29', async () => { |
| 194 | + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-')); |
| 195 | + const localFile = path.join(tempDir, 'test-upload.gcode'); |
| 196 | + const fileData = Buffer.from('G28\nG1 X10 Y10\nM104 S200\n', 'utf8'); |
| 197 | + fs.writeFileSync(localFile, fileData); |
| 198 | + |
| 199 | + const startCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'test-upload.gcode')}\n`; |
| 200 | + const { client, writes } = createUploadTestClient({ |
| 201 | + [startCommand]: 'CMD M28 Received.\n/data/test-upload.gcode\n', |
| 202 | + [`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\n', |
| 203 | + }); |
| 204 | + |
| 205 | + try { |
| 206 | + await expect(client.uploadFile(localFile)).resolves.toBe(true); |
| 207 | + } finally { |
| 208 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 209 | + } |
| 210 | + |
| 211 | + const textWrites = writes.filter((entry): entry is string => typeof entry === 'string'); |
| 212 | + const binaryWrites = writes.filter((entry): entry is Buffer => Buffer.isBuffer(entry)); |
| 213 | + |
| 214 | + expect(textWrites).toEqual([startCommand, `${GCodes.CmdCompleteFileUpload}\n`]); |
| 215 | + expect(Buffer.concat(binaryWrites)).toEqual(fileData); |
| 216 | + }); |
| 217 | + |
| 218 | + it('should normalize legacy prefixes in the requested remote file name', async () => { |
| 219 | + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-')); |
| 220 | + const localFile = path.join(tempDir, 'local-name.gcode'); |
| 221 | + const fileData = Buffer.from('M105\n', 'utf8'); |
| 222 | + fs.writeFileSync(localFile, fileData); |
| 223 | + |
| 224 | + const normalizedStartCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'renamed.gx')}\n`; |
| 225 | + const { client, writes } = createUploadTestClient({ |
| 226 | + [normalizedStartCommand]: 'CMD M28 Received.\n/data/renamed.gx\n', |
| 227 | + [`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\n', |
| 228 | + }); |
| 229 | + |
| 230 | + try { |
| 231 | + await expect(client.uploadFile(localFile, '/data/renamed.gx')).resolves.toBe(true); |
| 232 | + } finally { |
| 233 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 234 | + } |
| 235 | + |
| 236 | + expect(writes[0]).toBe(normalizedStartCommand); |
| 237 | + }); |
| 238 | + |
| 239 | + it('should fail the upload when M29 reports a size mismatch', async () => { |
| 240 | + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-')); |
| 241 | + const localFile = path.join(tempDir, 'broken-upload.gcode'); |
| 242 | + const fileData = Buffer.from('G1 X5 Y5\n', 'utf8'); |
| 243 | + fs.writeFileSync(localFile, fileData); |
| 244 | + |
| 245 | + const startCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'broken-upload.gcode')}\n`; |
| 246 | + const { client } = createUploadTestClient({ |
| 247 | + [startCommand]: 'CMD M28 Received.\n/data/broken-upload.gcode\n', |
| 248 | + [`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\nFile Is Not Available\n', |
| 249 | + }); |
| 250 | + |
| 251 | + try { |
| 252 | + await expect(client.uploadFile(localFile)).resolves.toBe(false); |
| 253 | + } finally { |
| 254 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 255 | + } |
| 256 | + }); |
| 257 | + }); |
129 | 258 | }); |
0 commit comments