From 0fd75b8a675fb0e220cd8b3f02295ae318b28f57 Mon Sep 17 00:00:00 2001 From: Jesper van den Ende Date: Sat, 6 Jan 2024 14:18:40 +0100 Subject: [PATCH] feat(studio connections): Support for binary serialization of messages (#853) --- src/inspector/InspectorManager.js | 5 +- .../studioConnections/DiscoveryManager.js | 16 +- .../studioConnections/StudioConnection.js | 137 +++++++-- .../messageHandlers/InternalMessageHandler.js | 3 + .../messageHandlers/MessageHandler.js | 16 +- src/util/TypedMessenger/TypedMessenger.js | 8 +- .../StudioConnectionsManager.js | 4 +- .../src/network/studioConnections/handlers.js | 60 +++- .../responseHandlers/fileSystem.js | 103 +++++++ .../MemoryFileSystemWritableFileStream.js | 2 +- .../src/inspector/InspectorManager/shared.js | 2 +- .../DiscoveryManager.test.js | 1 + .../StudioConnection.test.js | 264 ++++++++++++++++++ .../InternalDiscoveryMethod.test.js | 4 +- .../src/network/studioConnections/shared.js | 100 +++++++ .../studio/src/util/fileSystems/all/shared.js | 104 ++++--- .../fileSystems/all/tests/createDir.test.js | 7 +- .../util/fileSystems/all/tests/delete.test.js | 15 +- .../util/fileSystems/all/tests/isFile.test.js | 1 + .../util/fileSystems/all/tests/move.test.js | 23 +- .../fileSystems/all/tests/readDir.test.js | 1 + .../fileSystems/all/tests/readFile.test.js | 6 +- .../fileSystems/all/tests/rootName.test.js | 9 +- .../fileSystems/all/tests/writeFile.test.js | 88 +++++- .../all/tests/writeFileStream.test.js | 10 +- 25 files changed, 852 insertions(+), 137 deletions(-) create mode 100644 test/unit/src/network/studioConnections/StudioConnection.test.js create mode 100644 test/unit/src/network/studioConnections/shared.js diff --git a/src/inspector/InspectorManager.js b/src/inspector/InspectorManager.js index 9d147ae3b..3283c487d 100644 --- a/src/inspector/InspectorManager.js +++ b/src/inspector/InspectorManager.js @@ -102,7 +102,10 @@ export class InspectorManager { * @private */ getResponseHandlers() { - return { + /** @satisfies {import("../network/studioConnections/DiscoveryManager.js").ConnectionRequestAcceptOptions} */ + const handlers = { + reliableResponseHandlers: {}, }; + return handlers; } } diff --git a/src/network/studioConnections/DiscoveryManager.js b/src/network/studioConnections/DiscoveryManager.js index cbc288301..25777961a 100644 --- a/src/network/studioConnections/DiscoveryManager.js +++ b/src/network/studioConnections/DiscoveryManager.js @@ -42,13 +42,23 @@ import {StudioConnection} from "./StudioConnection.js"; * When a correct token is provided, the connection is accepted regardless of any origin allow lists or preferences. */ +/** + * @template {import("../../mod.js").TypedMessengerSignatures} TReliableResponseHandlers + * @typedef ConnectionRequestAcceptOptions + * @property {TReliableResponseHandlers} [reliableResponseHandlers] + * @property {Object ArrayBuffer | Promise>} [requestSerializers] + * @property {Object unknown[] | Promise>} [requestDeserializers] + * @property {Object ArrayBuffer | Promise>} [responseSerializers] + * @property {Object unknown | Promise>} [responseDeserializers] + */ + /** * @typedef OnConnectionCreatedRequest * @property {import("../../mod.js").UuidString} otherClientUuid * @property {boolean} initiatedByMe * @property {ConnectionRequestData} connectionRequestData * @property {ClientType} clientType - * @property {(reliableResponseHandlers: T) => StudioConnection} accept Accepts the connection and + * @property {(options: ConnectionRequestAcceptOptions) => StudioConnection} accept Accepts the connection and * returns a StudioConnection with the provided response handlers. * If none of the registered callbacks call `accept()` (synchronously), the connection will be closed immediately. * @property {() => void} reject Closes the connection and notifies the other end that the connection was not accepted. @@ -145,11 +155,11 @@ export class DiscoveryManager { clientType: messageHandler.clientType, initiatedByMe: messageHandler.initiatedByMe, connectionRequestData: messageHandler.connectionRequestData, - accept: reliableResponseHandlers => { + accept: options => { assertFirstCall(); accepted = true; messageHandler.requestAccepted(); - return new StudioConnection(messageHandler, reliableResponseHandlers); + return new StudioConnection(messageHandler, options); }, reject() { assertFirstCall(); diff --git a/src/network/studioConnections/StudioConnection.js b/src/network/studioConnections/StudioConnection.js index 10bd03aa1..c55b723a8 100644 --- a/src/network/studioConnections/StudioConnection.js +++ b/src/network/studioConnections/StudioConnection.js @@ -1,65 +1,164 @@ +import {StorageType, binaryToObject, createObjectToBinaryOptions, objectToBinary} from "../../util/binarySerialization.js"; +import {deserializeErrorHook, serializeErrorHook} from "../../util/TypedMessenger/errorSerialization.js"; import {TypedMessenger} from "../../util/TypedMessenger/TypedMessenger.js"; +const typedMessengerSendDataBinaryOpts = createObjectToBinaryOptions({ + structure: [ + StorageType.UNION_ARRAY, + { + id: StorageType.UINT16, + type: StorageType.STRING, + args: StorageType.ARRAY_BUFFER, + }, + { + id: StorageType.UINT16, + type: StorageType.STRING, + returnValue: StorageType.ARRAY_BUFFER, + didThrow: StorageType.BOOL, + }, + { + json: StorageType.STRING, + }, + ], + nameIds: { + id: 1, + type: 2, + args: 3, + returnValue: 4, + didThrow: 5, + json: 6, + }, +}); + /** * @template {import("../../mod.js").TypedMessengerSignatures} TReliableRespondHandlers * @template {import("../../mod.js").TypedMessengerSignatures} TReliableRequestHandlers */ export class StudioConnection { + #messageHandler; + /** * @param {import("./messageHandlers/MessageHandler.js").MessageHandler} messageHandler - * @param {TReliableRespondHandlers} reliableResponseHandlers + * @param {import("./DiscoveryManager.js").ConnectionRequestAcceptOptions} options */ - constructor(messageHandler, reliableResponseHandlers) { - /** @private */ - this.messageHandler = messageHandler; + constructor(messageHandler, options) { + this.#messageHandler = messageHandler; /** @type {TypedMessenger} */ - this.messenger = new TypedMessenger(); - this.messenger.setResponseHandlers(reliableResponseHandlers); - this.messenger.setSendHandler(data => { - messageHandler.send(data.sendData, {transfer: data.transfer}); + this.messenger = new TypedMessenger({ + deserializeErrorHook, + serializeErrorHook, + }); + this.messenger.setResponseHandlers(options.reliableResponseHandlers || /** @type {TReliableRespondHandlers} */ ({})); + this.messenger.setSendHandler(async data => { + if (messageHandler.supportsSerialization) { + await messageHandler.send(data.sendData, {transfer: data.transfer}); + } else { + const castType = /** @type {string} */ (data.sendData.type); + /** @type {ArrayBuffer} */ + let buffer; + if (data.sendData.direction == "request" && options.requestSerializers && options.requestSerializers[castType]) { + const serializedArguments = await options.requestSerializers[castType](...data.sendData.args); + buffer = objectToBinary({ + id: data.sendData.id, + type: castType, + args: serializedArguments, + }, typedMessengerSendDataBinaryOpts); + } else if (data.sendData.direction == "response" && options.responseSerializers && options.responseSerializers[castType] && !data.sendData.didThrow) { + const serializedReturnType = await options.responseSerializers[castType](data.sendData.returnValue); + buffer = objectToBinary({ + id: data.sendData.id, + type: castType, + didThrow: data.sendData.didThrow, + returnValue: serializedReturnType, + }, typedMessengerSendDataBinaryOpts); + } else { + buffer = objectToBinary({ + json: JSON.stringify(data.sendData), + }, typedMessengerSendDataBinaryOpts); + } + await messageHandler.send(buffer); + } }); - messageHandler.onMessage(data => { - const castData = /** @type {import("../../mod.js").TypedMessengerMessageSendData} */ (data); - this.messenger.handleReceivedMessage(castData); + messageHandler.onMessage(async data => { + /** @type {import("../../mod.js").TypedMessengerMessageSendData} */ + let decodedData; + if (messageHandler.supportsSerialization) { + decodedData = /** @type {import("../../mod.js").TypedMessengerMessageSendData} */ (data); + } else { + if (!(data instanceof ArrayBuffer)) { + throw new Error("This message handler is expected to only receive ArrayBuffer messages."); + } + const decoded = binaryToObject(data, typedMessengerSendDataBinaryOpts); + if ("args" in decoded) { + if (!options.requestDeserializers || !options.requestDeserializers[decoded.type]) { + throw new Error(`Unexpected serialized request message was received for "${decoded.type}". The message was serialized by the sender in the 'requestSerializers' object, but no deserializer was defined in the 'requestDeserializers' object.`); + } + const decodedArgs = await options.requestDeserializers[decoded.type](decoded.args); + decodedData = { + direction: "request", + type: decoded.type, + id: decoded.id, + args: /** @type {any} */ (decodedArgs), + }; + } else if ("returnValue" in decoded) { + if (!options.responseDeserializers || !options.responseDeserializers[decoded.type]) { + throw new Error(`Unexpected serialized response message was received for "${decoded.type}". The message was serialized by the sender in the 'responseSerializers' object, but no deserializer was defined in the 'responseDeserializers' object.`); + } + const decodedReturnValue = await options.responseDeserializers[decoded.type](decoded.returnValue); + decodedData = { + direction: "response", + type: decoded.type, + id: decoded.id, + didThrow: decoded.didThrow, + returnValue: decodedReturnValue, + }; + } else if ("json" in decoded) { + decodedData = JSON.parse(decoded.json); + } else { + throw new Error("An error occurred while deserializing the message."); + } + } + const castData = /** @type {import("../../mod.js").TypedMessengerMessageSendData} */ (decodedData); + await this.messenger.handleReceivedMessage(castData); }); } get otherClientUuid() { - return this.messageHandler.otherClientUuid; + return this.#messageHandler.otherClientUuid; } get clientType() { - return this.messageHandler.clientType; + return this.#messageHandler.clientType; } get connectionType() { - return this.messageHandler.connectionType; + return this.#messageHandler.connectionType; } /** * True when the connection was initiated by our client (i.e. the client that holds the instance of this class in memory). */ get initiatedByMe() { - return this.messageHandler.initiatedByMe; + return this.#messageHandler.initiatedByMe; } get projectMetadata() { - return this.messageHandler.projectMetadata; + return this.#messageHandler.projectMetadata; } close() { - this.messageHandler.close(); + this.#messageHandler.close(); } /** * @param {import("./messageHandlers/MessageHandler.js").OnStatusChangeCallback} cb */ onStatusChange(cb) { - this.messageHandler.onStatusChange(cb); + this.#messageHandler.onStatusChange(cb); } get status() { - return this.messageHandler.status; + return this.#messageHandler.status; } } diff --git a/src/network/studioConnections/messageHandlers/InternalMessageHandler.js b/src/network/studioConnections/messageHandlers/InternalMessageHandler.js index 802d9b316..f0b3fd1eb 100644 --- a/src/network/studioConnections/messageHandlers/InternalMessageHandler.js +++ b/src/network/studioConnections/messageHandlers/InternalMessageHandler.js @@ -8,6 +8,9 @@ export class InternalMessageHandler extends MessageHandler { */ constructor(options, messagePort, onPermissionResult) { super(options); + + this.supportsSerialization = true; + /** @private */ this.messagePort = messagePort; /** @private */ diff --git a/src/network/studioConnections/messageHandlers/MessageHandler.js b/src/network/studioConnections/messageHandlers/MessageHandler.js index 723055e50..f5ba85839 100644 --- a/src/network/studioConnections/messageHandlers/MessageHandler.js +++ b/src/network/studioConnections/messageHandlers/MessageHandler.js @@ -12,7 +12,7 @@ */ export class MessageHandler { - /** @typedef {(data: unknown) => void} OnMessageCallback */ + /** @typedef {(data: unknown) => Promise} OnMessageCallback */ /** * @param {MessageHandlerOptions} options @@ -33,6 +33,13 @@ export class MessageHandler { this.onMessageCbs = new Set(); /** @type {MessageHandlerStatus} */ this.status = "disconnected"; + /** + * Set this to true when the message handler supports serializing arbitrary data. + * This is generally only supported with messaging mechanisms that use `postMessage` like functions. + * When this is false, {@linkcode send} will only receive `ArrayBuffer`s which will be serialized + * and deserialized by the `StudioConnection` class. + */ + this.supportsSerialization = false; /** @private @type {Set} */ this.onStatusChangeCbs = new Set(); @@ -76,6 +83,7 @@ export class MessageHandler { * @param {unknown} data * @param {object} [sendOptions] * @param {Transferable[]} [sendOptions.transfer] + * @returns {void | Promise} */ send(data, sendOptions) {} @@ -102,8 +110,10 @@ export class MessageHandler { * @protected * @param {unknown} data */ - handleMessageReceived(data) { - this.onMessageCbs.forEach(cb => cb(data)); + async handleMessageReceived(data) { + for (const cb of this.onMessageCbs) { + await cb(data); + } } /** diff --git a/src/util/TypedMessenger/TypedMessenger.js b/src/util/TypedMessenger/TypedMessenger.js index 9d2cee242..2c25cd1a5 100644 --- a/src/util/TypedMessenger/TypedMessenger.js +++ b/src/util/TypedMessenger/TypedMessenger.js @@ -116,11 +116,12 @@ import {TimeoutError} from "../TimeoutError.js"; */ /** + * @template {any} [TReturn = any] * @typedef TypedMessengerRespondOptions * @property {Transferable[]} [transfer] An array of objects that should be transferred. * For this to work, the `TypedMessenger.setSendHandler()` callback should pass the `transfer` data to the correct `postMessage()` argument. * For more info see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects. - * @property {any} [returnValue] The value that should be sent to the requester. + * @property {TReturn} [returnValue] The value that should be sent to the requester. * @property {boolean} [respond] Defaults to true, set to false to not send any response at all. * * **Warning:** Make sure to also set `expectResponse` to `false` on the sending end to avoid memory leaks. @@ -129,7 +130,10 @@ import {TimeoutError} from "../TimeoutError.js"; * Alternatively you could set a `timeout` or `globalTimeout`, causing the promise to reject once the timeout is reached. */ -/** @typedef {{"$respondOptions"?: TypedMessengerRespondOptions}} TypedMessengerRequestHandlerReturn */ +/** + * @template {any} [TReturn = any] + * @typedef {{"$respondOptions"?: TypedMessengerRespondOptions}} TypedMessengerRequestHandlerReturn + */ /** * @template {TypedMessengerSignatures} TReq diff --git a/studio/src/network/studioConnections/StudioConnectionsManager.js b/studio/src/network/studioConnections/StudioConnectionsManager.js index 15e82e00b..7f9194053 100644 --- a/studio/src/network/studioConnections/StudioConnectionsManager.js +++ b/studio/src/network/studioConnections/StudioConnectionsManager.js @@ -1,7 +1,7 @@ import {DiscoveryManager} from "../../../../src/network/studioConnections/DiscoveryManager.js"; import {InternalDiscoveryMethod} from "../../../../src/network/studioConnections/discoveryMethods/InternalDiscoveryMethod.js"; import {WebRtcDiscoveryMethod} from "../../../../src/network/studioConnections/discoveryMethods/WebRtcDiscoveryMethod.js"; -import {createStudioHostHandlers, createStudioInspectorHandlers} from "./handlers.js"; +import {createStudioClientHandlers, createStudioHostHandlers, createStudioInspectorHandlers} from "./handlers.js"; /** * @typedef {import("../../../../src/network/studioConnections/DiscoveryManager.js").AvailableConnectionWithType & {connectionState: import("../../../../src/network/studioConnections/messageHandlers/MessageHandler.js").MessageHandlerStatus}} StudioConnectionData @@ -106,7 +106,7 @@ export class StudioConnectionsManager { } acceptHandler = () => { /** @type {import("./handlers.js").StudioClientHostConnection} */ - const connection = connectionRequest.accept({}); + const connection = connectionRequest.accept(createStudioClientHandlers()); this.#projectManager.assignRemoteConnection(connection); this.#addActiveConnection(connection); }; diff --git a/studio/src/network/studioConnections/handlers.js b/studio/src/network/studioConnections/handlers.js index bdcad18f3..3bd250405 100644 --- a/studio/src/network/studioConnections/handlers.js +++ b/studio/src/network/studioConnections/handlers.js @@ -1,21 +1,69 @@ -import {createFileSystemHandlers} from "./responseHandlers/fileSystem.js"; +import {createFileSystemHandlers, createFileSystemRequestDeserializers, createFileSystemRequestSerializers, createFileSystemResponseDeserializers, createFileSystemResponseSerializers} from "./responseHandlers/fileSystem.js"; import {createAssetsHandlers} from "./responseHandlers/assets.js"; -/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection<{}, ReturnType>} StudioClientHostConnection */ +/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection<{}, StudioConnectionRequestHandlersToTypedMessengerHandlers["reliableResponseHandlers"]>>} StudioClientHostConnection */ -/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection, ReturnType>} InspectorStudioConnection */ -/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection, ReturnType>} StudioInspectorConnection */ +/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection["reliableResponseHandlers"]>, StudioConnectionRequestHandlersToTypedMessengerHandlers["reliableResponseHandlers"]>>} InspectorStudioConnection */ +/** @typedef {import("../../../../src/network/studioConnections/StudioConnection.js").StudioConnection["reliableResponseHandlers"]>, StudioConnectionRequestHandlersToTypedMessengerHandlers["reliableResponseHandlers"]>>} StudioInspectorConnection */ + +/** + * @template {any[]} TArgs + * @template TReturn + * @typedef StudioConnectionRequestHandlerObject + * @property {(...args: TArgs) => TReturn | import("../../../../src/util/TypedMessenger/TypedMessenger.js").TypedMessengerRequestHandlerReturn | Promise>} handler + * @property {(buffer: ArrayBuffer) => TArgs} [requestDeserializer] + * @property {(returnValue: TReturn) => ArrayBuffer} [responseSerializer] + */ +/** + * @typedef {Object | ((...args: any[]) => any)>} StudioConnectionRequestHandlers + */ + +/** + * @template {StudioConnectionRequestHandlers} THandlers + * @typedef {{ + * [x in keyof THandlers]: THandlers[x] extends StudioConnectionRequestHandlerObject ? THandlers[x]["handler"] : THandlers[x]; + * }} StudioConnectionRequestHandlersToTypedMessengerHandlers + */ /** * @param {import("../../util/fileSystems/StudioFileSystem.js").StudioFileSystem} fileSystem */ export function createStudioHostHandlers(fileSystem) { - return createFileSystemHandlers(fileSystem); + /** @satisfies {import("../../../../src/network/studioConnections/DiscoveryManager.js").ConnectionRequestAcceptOptions} */ + const handlers = { + reliableResponseHandlers: { + ...createFileSystemHandlers(fileSystem), + }, + requestDeserializers: { + ...createFileSystemRequestDeserializers(), + }, + responseSerializers: { + ...createFileSystemResponseSerializers(), + }, + }; + return handlers; +} + +export function createStudioClientHandlers() { + /** @satisfies {import("../../../../src/network/studioConnections/DiscoveryManager.js").ConnectionRequestAcceptOptions} */ + const handlers = { + requestSerializers: { + ...createFileSystemRequestSerializers(), + }, + responseDeserializers: { + ...createFileSystemResponseDeserializers(), + }, + }; + return handlers; } /** * @param {import("../../assets/AssetManager.js").AssetManager} assetManager */ export function createStudioInspectorHandlers(assetManager) { - return createAssetsHandlers(assetManager); + return { + reliableResponseHandlers: { + ...createAssetsHandlers(assetManager), + }, + }; } diff --git a/studio/src/network/studioConnections/responseHandlers/fileSystem.js b/studio/src/network/studioConnections/responseHandlers/fileSystem.js index adf0b03f6..ad64033cb 100644 --- a/studio/src/network/studioConnections/responseHandlers/fileSystem.js +++ b/studio/src/network/studioConnections/responseHandlers/fileSystem.js @@ -1,3 +1,5 @@ +import {StorageType, binaryToObject, createObjectToBinaryOptions, objectToBinary} from "../../../../../src/util/binarySerialization.js"; + /** * @param {import("../../../util/fileSystems/StudioFileSystem.js").StudioFileSystem} fileSystem */ @@ -48,3 +50,104 @@ export function createFileSystemHandlers(fileSystem) { }, }; } + +const serializeFileBinaryOpts = createObjectToBinaryOptions({ + structure: { + buffer: StorageType.ARRAY_BUFFER, + name: StorageType.STRING, + type: StorageType.STRING, + lastModified: StorageType.FLOAT64, + }, + nameIds: { + buffer: 0, + name: 1, + type: 2, + lastModified: 3, + }, +}); + +/** + * @param {File} file + */ +async function serializeFile(file) { + return objectToBinary({ + buffer: await file.arrayBuffer(), + name: file.name, + type: file.type, + lastModified: file.lastModified, + }, serializeFileBinaryOpts); +} + +/** + * @param {ArrayBuffer} buffer + */ +function deserializeFile(buffer) { + const fileData = binaryToObject(buffer, serializeFileBinaryOpts); + return new File([fileData.buffer], fileData.name, { + type: fileData.type, + lastModified: fileData.lastModified, + }); +} + +const serializeWriteFileBinaryOpts = createObjectToBinaryOptions({ + structure: { + path: [StorageType.STRING], + file: StorageType.ARRAY_BUFFER, + }, + nameIds: { + path: 1, + file: 2, + }, +}); + +export function createFileSystemRequestSerializers() { + return { + /** + * @param {import("../../../util/fileSystems/StudioFileSystem.js").StudioFileSystemPath} path + * @param {import("../../../util/fileSystems/StudioFileSystem.js").AllowedWriteFileTypes} file + */ + "fileSystem.writeFile": async (path, file) => { + const fileObject = new File([file], ""); + const serializedFile = await serializeFile(fileObject); + return objectToBinary({ + path, + file: serializedFile, + }, serializeWriteFileBinaryOpts); + }, + }; +} + +export function createFileSystemRequestDeserializers() { + return { + /** + * @param {ArrayBuffer} buffer + */ + "fileSystem.writeFile": buffer => { + const deserialized = binaryToObject(buffer, serializeWriteFileBinaryOpts); + const deserializedFile = deserializeFile(deserialized.file); + return [deserialized.path, deserializedFile]; + }, + }; +} + +export function createFileSystemResponseSerializers() { + return { + /** + * @param {File} file + */ + "fileSystem.readFile": async file => { + return await serializeFile(file); + }, + }; +} + +export function createFileSystemResponseDeserializers() { + return { + /** + * @param {ArrayBuffer} buffer + */ + "fileSystem.readFile": buffer => { + return deserializeFile(buffer); + }, + }; +} diff --git a/studio/src/util/fileSystems/MemoryFileSystemWritableFileStream.js b/studio/src/util/fileSystems/MemoryFileSystemWritableFileStream.js index 4b3238c9b..41898b098 100644 --- a/studio/src/util/fileSystems/MemoryFileSystemWritableFileStream.js +++ b/studio/src/util/fileSystems/MemoryFileSystemWritableFileStream.js @@ -12,7 +12,7 @@ export class MemoryFileSystemWritableFileStream extends WritableStream { } /** - * @param {FileSystemWriteChunkType} data + * @param {FileSystemWriteChunkType} data */ async write(data) { if (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || data instanceof Blob || typeof data == "string") { diff --git a/test/unit/src/inspector/InspectorManager/shared.js b/test/unit/src/inspector/InspectorManager/shared.js index 9356ed136..083198d2c 100644 --- a/test/unit/src/inspector/InspectorManager/shared.js +++ b/test/unit/src/inspector/InspectorManager/shared.js @@ -13,7 +13,7 @@ export function createMockInspectorManager({ const mockAssetManager = /** @type {import("../../../../../studio/src/assets/AssetManager.js").AssetManager} */ ({}); const handlers = createStudioInspectorHandlers(mockAssetManager); const studioHostMessenger = new TypedMessenger(); - studioHostMessenger.setResponseHandlers(handlers); + studioHostMessenger.setResponseHandlers(handlers.reliableResponseHandlers); const inspectorMessenger = new TypedMessenger(); // Link the two messengers to each other diff --git a/test/unit/src/network/studioConnections/DiscoveryManager.test.js b/test/unit/src/network/studioConnections/DiscoveryManager.test.js index f435e0abb..bc65a9bc2 100644 --- a/test/unit/src/network/studioConnections/DiscoveryManager.test.js +++ b/test/unit/src/network/studioConnections/DiscoveryManager.test.js @@ -241,6 +241,7 @@ Deno.test({ }, Error, "The connection request has already been accepted."); }, }); + Deno.test({ name: "Rejection a connection closes the connection", fn() { diff --git a/test/unit/src/network/studioConnections/StudioConnection.test.js b/test/unit/src/network/studioConnections/StudioConnection.test.js new file mode 100644 index 000000000..5344587e5 --- /dev/null +++ b/test/unit/src/network/studioConnections/StudioConnection.test.js @@ -0,0 +1,264 @@ +import {assert, assertEquals, assertInstanceOf, assertRejects} from "std/testing/asserts.ts"; +import {StudioConnection} from "../../../../../src/network/studioConnections/StudioConnection.js"; +import {assertSpyCalls} from "std/testing/mock.ts"; +import {ExtendedMessageHandler, connectMessageHandlers} from "./shared.js"; + +Deno.test({ + name: "Exposes properties from the message handler", + fn() { + const connection = new StudioConnection(new ExtendedMessageHandler(), {}); + assertEquals(connection.otherClientUuid, "otherClientUuid"); + assertEquals(connection.clientType, "inspector"); + assertEquals(connection.connectionType, "testConnectionType"); + assertEquals(connection.initiatedByMe, false); + assertEquals(connection.projectMetadata, { + fileSystemHasWritePermissions: true, + name: "test project name", + uuid: "test project uuid", + }); + assertEquals(connection.status, "connecting"); + }, +}); + +Deno.test({ + name: "Calling close closes the message handler", + fn() { + const handler = new ExtendedMessageHandler(); + const connection = new StudioConnection(handler, {}); + connection.close(); + assertSpyCalls(handler.closeSpy, 1); + }, +}); + +Deno.test({ + name: "Messages are directly passed to the other end when serialization is supported", + async fn() { + const messageHandlerA = new ExtendedMessageHandler({supportsSerialization: true}); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler({supportsSerialization: true}); + const connectionB = new StudioConnection(messageHandlerB, {}); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + const result = await connectionB.messenger.send.foo(42); + assertEquals(result, 42); + + assertSpyCalls(messageHandlerA.sendSpy, 1); + assert(!(messageHandlerA.sendSpy.calls[0].args[0] instanceof ArrayBuffer)); + assertSpyCalls(messageHandlerB.sendSpy, 1); + assert(!(messageHandlerB.sendSpy.calls[0].args[0] instanceof ArrayBuffer)); + }, +}); + +Deno.test({ + name: "Messages are serialized to json when serialization is not supported", + async fn() { + const messageHandlerA = new ExtendedMessageHandler(); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler(); + const connectionB = new StudioConnection(messageHandlerB, {}); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + const result = await connectionB.messenger.send.foo(42); + assertEquals(result, 42); + + assertSpyCalls(messageHandlerA.sendSpy, 1); + assertInstanceOf(messageHandlerA.sendSpy.calls[0].args[0], ArrayBuffer); + assertSpyCalls(messageHandlerB.sendSpy, 1); + assertInstanceOf(messageHandlerB.sendSpy.calls[0].args[0], ArrayBuffer); + }, +}); + +Deno.test({ + name: "Requests are serialized and deserialized when hooks are specified", + async fn() { + const messageHandlerA = new ExtendedMessageHandler(); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + requestDeserializers: { + foo: buffer => { + const view = new DataView(buffer); + return [view.getUint8(0)]; + }, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler(); + const connectionB = new StudioConnection(messageHandlerB, { + requestSerializers: { + /** + * @param {number} num + */ + foo: num => { + const buffer = new ArrayBuffer(1); + const view = new DataView(buffer); + view.setUint8(0, num); + return buffer; + }, + }, + }); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + const result = await connectionB.messenger.send.foo(42); + assertEquals(result, 42); + const result2 = await connectionB.messenger.send.foo(513); + assertEquals(result2, 1); + }, +}); + +Deno.test({ + name: "Responses are serialized and deserialized when hooks are specified", + async fn() { + const messageHandlerA = new ExtendedMessageHandler(); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + responseSerializers: { + /** + * @param {number} num + */ + foo: num => { + const buffer = new ArrayBuffer(1); + const view = new DataView(buffer); + view.setUint8(0, num); + return buffer; + }, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler(); + const connectionB = new StudioConnection(messageHandlerB, { + responseDeserializers: { + foo: buffer => { + const view = new DataView(buffer); + return view.getUint8(0); + }, + }, + }); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + const result = await connectionB.messenger.send.foo(42); + assertEquals(result, 42); + const result2 = await connectionB.messenger.send.foo(513); + assertEquals(result2, 1); + }, +}); + +Deno.test({ + name: "Throws when a request deserializer is missing", + async fn() { + const messageHandler = new ExtendedMessageHandler(); + new StudioConnection(messageHandler, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + requestDeserializers: {}, + }); + + await assertRejects(async () => { + await messageHandler.handleMessageReceived({}); + }, Error, "This message handler is expected to only receive ArrayBuffer messages."); + }, +}); + +Deno.test({ + name: "Throws when a request deserializer is missing", + async fn() { + const messageHandlerA = new ExtendedMessageHandler(); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler(); + const connectionB = new StudioConnection(messageHandlerB, { + requestSerializers: { + /** + * @param {number} num + */ + foo: num => { + const buffer = new ArrayBuffer(1); + const view = new DataView(buffer); + view.setUint8(0, num); + return buffer; + }, + }, + }); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + await assertRejects(async () => { + await connectionB.messenger.send.foo(42); + }, Error, `Unexpected serialized request message was received for "foo". The message was serialized by the sender in the 'requestSerializers' object, but no deserializer was defined in the 'requestDeserializers' object.`); + }, +}); + +Deno.test({ + name: "Throws when a response deserializer is missing", + async fn() { + const messageHandlerA = new ExtendedMessageHandler(); + new StudioConnection(messageHandlerA, { + reliableResponseHandlers: { + /** + * @param {number} num + */ + foo: num => num, + }, + responseSerializers: { + /** + * @param {number} num + */ + foo: num => { + const buffer = new ArrayBuffer(1); + const view = new DataView(buffer); + view.setUint8(0, num); + return buffer; + }, + }, + }); + + const messageHandlerB = new ExtendedMessageHandler(); + const connectionB = new StudioConnection(messageHandlerB, {}); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + await assertRejects(async () => { + await connectionB.messenger.send.foo(42); + }, Error, `Unexpected serialized response message was received for "foo". The message was serialized by the sender in the 'responseSerializers' object, but no deserializer was defined in the 'responseDeserializers' object.`); + }, +}); diff --git a/test/unit/src/network/studioConnections/discoveryMethods/InternalDiscoveryMethod.test.js b/test/unit/src/network/studioConnections/discoveryMethods/InternalDiscoveryMethod.test.js index 60336057b..85ce7ee9a 100644 --- a/test/unit/src/network/studioConnections/discoveryMethods/InternalDiscoveryMethod.test.js +++ b/test/unit/src/network/studioConnections/discoveryMethods/InternalDiscoveryMethod.test.js @@ -471,8 +471,8 @@ Deno.test({ assertEquals(handler2.status, "connected"); // Check if the two ports are connected - /** @type {(data: unknown) => void} */ - const messageSpyFn = () => {}; + /** @type {(data: unknown) => Promise} */ + const messageSpyFn = async () => {}; const messageSpy1 = spy(messageSpyFn); const messageSpy2 = spy(messageSpyFn); handler1.onMessage(messageSpy1); diff --git a/test/unit/src/network/studioConnections/shared.js b/test/unit/src/network/studioConnections/shared.js new file mode 100644 index 000000000..2b27a2221 --- /dev/null +++ b/test/unit/src/network/studioConnections/shared.js @@ -0,0 +1,100 @@ +import {spy} from "std/testing/mock.ts"; +import {MessageHandler} from "../../../../../src/network/studioConnections/messageHandlers/MessageHandler.js"; +import {StudioConnection} from "../../../../../src/network/studioConnections/StudioConnection.js"; + +export class ExtendedMessageHandler extends MessageHandler { + /** @type {Set<(data: any) => void | Promise>} */ + #onSendCalledCbs = new Set(); + constructor({ + supportsSerialization = false, + } = {}) { + super({ + otherClientUuid: "otherClientUuid", + availableConnectionData: { + clientType: "inspector", + projectMetadata: { + fileSystemHasWritePermissions: true, + name: "test project name", + uuid: "test project uuid", + }, + id: "otherClientUuid", + }, + connectionType: "testConnectionType", + initiatedByMe: false, + connectionRequestData: { + token: "connection token", + }, + }); + + this.supportsSerialization = supportsSerialization; + this.setStatus("connecting"); + + this.sendSpy = spy(this, "send"); + this.closeSpy = spy(this, "close"); + } + + /** + * @param {any} data + */ + async send(data) { + for (const cb of this.#onSendCalledCbs) { + await cb(data); + } + } + + /** + * @param {(data: any) => void | Promise} cb + */ + onSendCalled(cb) { + this.#onSendCalledCbs.add(cb); + } + + /** + * @param {any} data + */ + handleMessageReceived(data) { + return super.handleMessageReceived(data); + } + + /** + * @param {import("../../../../../src/network/studioConnections/messageHandlers/MessageHandler.js").MessageHandlerStatus} status + */ + setStatus(status) { + super.setStatus(status); + } +} + +/** + * @param {ExtendedMessageHandler} messageHandlerA + * @param {ExtendedMessageHandler} messageHandlerB + */ +export function connectMessageHandlers(messageHandlerA, messageHandlerB) { + messageHandlerA.onSendCalled(async data => { + await messageHandlerB.handleMessageReceived(data); + }); + messageHandlerB.onSendCalled(async data => { + await messageHandlerA.handleMessageReceived(data); + }); + messageHandlerA.setStatus("connected"); + messageHandlerB.setStatus("connected"); +} + +/** + * @template {import("../../../../../src/mod.js").TypedMessengerSignatures} TReliableRespondHandlers + * @template {import("../../../../../src/mod.js").TypedMessengerSignatures} TReliableRequestHandlers + * @param {import("../../../../../src/network/studioConnections/DiscoveryManager.js").ConnectionRequestAcceptOptions} handlersA + * @param {import("../../../../../src/network/studioConnections/DiscoveryManager.js").ConnectionRequestAcceptOptions} handlersB + */ +export function createLinkedStudioConnections(handlersA, handlersB, {supportsSerialization = false} = {}) { + const messageHandlerA = new ExtendedMessageHandler({supportsSerialization}); + /** @type {StudioConnection} */ + const connectionA = new StudioConnection(messageHandlerA, handlersA); + + const messageHandlerB = new ExtendedMessageHandler({supportsSerialization}); + /** @type {StudioConnection} */ + const connectionB = new StudioConnection(messageHandlerB, handlersB); + + connectMessageHandlers(messageHandlerA, messageHandlerB); + + return {connectionA, connectionB, messageHandlerA, messageHandlerB}; +} diff --git a/test/unit/studio/src/util/fileSystems/all/shared.js b/test/unit/studio/src/util/fileSystems/all/shared.js index 472e41164..9c3883595 100644 --- a/test/unit/studio/src/util/fileSystems/all/shared.js +++ b/test/unit/studio/src/util/fileSystems/all/shared.js @@ -2,9 +2,10 @@ import {FsaStudioFileSystem} from "../../../../../../../studio/src/util/fileSyst import {MemoryStudioFileSystem} from "../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; import {FakeHandle} from "../FsaStudioFileSystem/shared.js"; import {Importer} from "fake-imports"; -import {TypedMessenger, generateUuid} from "../../../../../../../src/mod.js"; +import {generateUuid} from "../../../../../../../src/mod.js"; import {RemoteStudioFileSystem} from "../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; -import {createFileSystemHandlers} from "../../../../../../../studio/src/network/studioConnections/responseHandlers/fileSystem.js"; +import {createFileSystemHandlers, createFileSystemRequestDeserializers, createFileSystemRequestSerializers, createFileSystemResponseDeserializers, createFileSystemResponseSerializers} from "../../../../../../../studio/src/network/studioConnections/responseHandlers/fileSystem.js"; +import {createLinkedStudioConnections} from "../../../../../src/network/studioConnections/shared.js"; const importer = new Importer(import.meta.url); importer.redirectModule("../../../../../../../src/util/IndexedDbUtil.js", "../../../../shared/MockIndexedDbUtil.js"); @@ -12,26 +13,65 @@ importer.redirectModule("../../../../../../../src/util/IndexedDbUtil.js", "../.. /** @type {import("../../../../../../../studio/src/util/fileSystems/IndexedDbStudioFileSystem.js")} */ const IndexedDbStudioFileSystemMod = await importer.import("../../../../../../../studio/src/util/fileSystems/IndexedDbStudioFileSystem.js"); const {IndexedDbStudioFileSystem} = IndexedDbStudioFileSystemMod; -export {IndexedDbStudioFileSystem}; const {forcePendingOperations: forcePendingOperationsImported} = await importer.import("../../../../../../../src/util/IndexedDbUtil.js"); const forcePendingIndexedDbOperations = /** @type {typeof import("../../../../shared/MockIndexedDbUtil.js").forcePendingOperations} */ (forcePendingOperationsImported); -/** @typedef {typeof FsaStudioFileSystem | typeof IndexedDbStudioFileSystem | typeof MemoryStudioFileSystem | typeof RemoteStudioFileSystem} FileSystemTypes */ +/** @typedef {"fsa" | "indexedDb" | "memory" | "remote" | "serialized-remote"} FileSystemTestTypes */ /** * @typedef FileSystemTestConfig - * @property {FileSystemTypes} ctor + * @property {FileSystemTestTypes} type * @property {(options?: CreateFsOptions) => import("../../../../../../../studio/src/util/fileSystems/StudioFileSystem.js").StudioFileSystem} create Should * create a new instance of the file system. * @property {(pending: boolean) => void} forcePendingOperations Should force all read and write promises to stay pending for this * file system type. */ +/** + * @param {FileSystemTestTypes} type + * @param {boolean} supportsSerialization + * @returns {FileSystemTestConfig} + */ +function createRemoteFileSystemTestConfig(type, supportsSerialization) { + return { + type, + create() { + const memoryFs = new MemoryStudioFileSystem(); + const {connectionB} = createLinkedStudioConnections({ + reliableResponseHandlers: { + ...createFileSystemHandlers(memoryFs), + }, + requestDeserializers: { + ...createFileSystemRequestDeserializers(), + }, + responseSerializers: { + ...createFileSystemResponseSerializers(), + }, + }, { + requestSerializers: { + ...createFileSystemRequestSerializers(), + }, + responseDeserializers: { + ...createFileSystemResponseDeserializers(), + }, + }, { + supportsSerialization, + }); + const remoteFs = new RemoteStudioFileSystem(); + remoteFs.setConnection(connectionB); + return remoteFs; + }, + forcePendingOperations(pending) { + throw new Error("Not yet implemented"); + }, + }; +} + /** @type {FileSystemTestConfig[]} */ const fileSystems = [ { - ctor: FsaStudioFileSystem, + type: "fsa", create() { const rootHandle = new FakeHandle("directory", "actualRoot"); return new FsaStudioFileSystem(/** @type {any} */ (rootHandle)); @@ -41,7 +81,7 @@ const fileSystems = [ }, }, { - ctor: IndexedDbStudioFileSystem, + type: "indexedDb", create({ disableStructuredClone = false, } = {}) { @@ -58,7 +98,7 @@ const fileSystems = [ }, }, { - ctor: MemoryStudioFileSystem, + type: "memory", create() { return new MemoryStudioFileSystem(); }, @@ -66,36 +106,8 @@ const fileSystems = [ throw new Error("Not yet implemented"); }, }, - { - ctor: RemoteStudioFileSystem, - create() { - const memoryFs = new MemoryStudioFileSystem(); - const handlers = createFileSystemHandlers(memoryFs); - const hostMessenger = new TypedMessenger(); - hostMessenger.setResponseHandlers(handlers); - const clientMessenger = new TypedMessenger(); - - // Link the two messengers to each other - hostMessenger.setSendHandler(data => { - clientMessenger.handleReceivedMessage(data.sendData); - }); - clientMessenger.setSendHandler(data => { - hostMessenger.handleReceivedMessage(data.sendData); - }); - - const clientConnection = /** @type {import("../../../../../../../studio/src/network/studioConnections/handlers.js").StudioClientHostConnection} */ ({ - messenger: /** @type {any} */ (clientMessenger), - onStatusChange(cb) {}, - status: "connected", - }); - const remoteFs = new RemoteStudioFileSystem(); - remoteFs.setConnection(clientConnection); - return remoteFs; - }, - forcePendingOperations(pending) { - throw new Error("Not yet implemented"); - }, - }, + createRemoteFileSystemTestConfig("remote", true), + createRemoteFileSystemTestConfig("serialized-remote", false), ]; /** @@ -109,10 +121,10 @@ const fileSystems = [ * @typedef FileSystemTest * @property {string} name * @property {(ctx: FileSystemTestContext) => (void | Promise)} fn - * @property {FileSystemTypes[] | boolean} [ignore] The file system types to ignore this test for. - * @property {FileSystemTypes[]} [exclude] The file system types to exclude, unlike `ignore` this does not + * @property {FileSystemTestTypes[] | boolean} [ignore] The file system types to ignore this test for. + * @property {FileSystemTestTypes[]} [exclude] The file system types to exclude, unlike `ignore` this does not * count against the ignored tests in the results, and instead this test is just completely omitted from the results. - * @property {boolean} [only] Runs only this test and no others. + * @property {boolean | FileSystemTestTypes} [only] Runs only this test and no others. */ /** @@ -129,18 +141,18 @@ const fileSystems = [ * @param {FileSystemTest} test */ export function testAll(test) { - for (const {ctor, create, forcePendingOperations} of fileSystems) { - if (test.exclude && test.exclude.includes(ctor)) continue; + for (const {type, create, forcePendingOperations} of fileSystems) { + if (test.exclude && test.exclude.includes(type)) continue; let ignore = false; if (test.ignore != undefined) { if (typeof test.ignore == "boolean") { ignore = test.ignore; } else { - ignore = test.ignore.includes(ctor); + ignore = test.ignore.includes(type); } } - const name = `${ctor.name}: ${test.name}`; + const name = `${type}: ${test.name}`; /** * @param {CreateFsOptions} [options] */ @@ -175,7 +187,7 @@ export function testAll(test) { Deno.test({ name, ignore, - only: test.only, + only: test.only === true || test.only == type, async fn() { await test.fn(ctx); }, diff --git a/test/unit/studio/src/util/fileSystems/all/tests/createDir.test.js b/test/unit/studio/src/util/fileSystems/all/tests/createDir.test.js index 88eb74425..29a594b16 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/createDir.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/createDir.test.js @@ -3,13 +3,10 @@ import {assertSpyCall, assertSpyCalls} from "std/testing/mock.ts"; import {testAll} from "../shared.js"; import {registerOnChangeSpy} from "../../shared.js"; import {waitForMicrotasks} from "../../../../../../shared/waitForMicroTasks.js"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {MemoryStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; testAll({ name: "createDir() should create a directory and fire onchange", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -71,7 +68,7 @@ testAll({ testAll({ name: "createDir() causes waitForWritesFinish to stay pending until done", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/delete.test.js b/test/unit/studio/src/util/fileSystems/all/tests/delete.test.js index 4f892fa1c..7a68b56a7 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/delete.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/delete.test.js @@ -1,16 +1,13 @@ import {assertEquals, assertRejects} from "std/testing/asserts.ts"; import {assertSpyCall, assertSpyCalls} from "std/testing/mock.ts"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {MemoryStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; import {assertPromiseResolved} from "../../../../../../shared/asserts.js"; import {waitForMicrotasks} from "../../../../../../shared/waitForMicroTasks.js"; import {registerOnChangeSpy} from "../../shared.js"; import {testAll} from "../shared.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; testAll({ name: "delete() should delete files and fire onChange", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -50,7 +47,7 @@ testAll({ testAll({ name: "delete() should throw when deleting a non-existent file", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createFs(); @@ -62,7 +59,7 @@ testAll({ testAll({ name: "delete() should throw when deleting a file with non-existent parent", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createFs(); @@ -74,7 +71,7 @@ testAll({ testAll({ name: "delete() should throw when deleting a non-empty directory with recursive=false", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -86,7 +83,7 @@ testAll({ testAll({ name: "delete() a directory with recursive = true", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -104,7 +101,7 @@ testAll({ testAll({ name: "delete() causes waitForWritesFinish to stay pending until done", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/isFile.test.js b/test/unit/studio/src/util/fileSystems/all/tests/isFile.test.js index 61c3745d0..87868eb0b 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/isFile.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/isFile.test.js @@ -47,6 +47,7 @@ testAll({ testAll({ name: "isFile while it is being created", + ignore: ["serialized-remote"], // TODO #855 async fn(ctx) { const fs = await ctx.createBasicFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/move.test.js b/test/unit/studio/src/util/fileSystems/all/tests/move.test.js index bc0196a20..25974278e 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/move.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/move.test.js @@ -1,14 +1,11 @@ import {assertEquals, assertRejects} from "std/testing/asserts.ts"; import {assertSpyCall, assertSpyCalls} from "std/testing/mock.ts"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {MemoryStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; import {registerOnChangeSpy} from "../../shared.js"; import {testAll} from "../shared.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; testAll({ name: "move() rename a file", - ignore: [MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -55,7 +52,7 @@ testAll({ testAll({ name: "move() a file", - ignore: [MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -106,7 +103,7 @@ testAll({ testAll({ name: "move() rename a directory with files", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -146,7 +143,7 @@ testAll({ testAll({ name: "move() rename a directory with dirs", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -163,7 +160,7 @@ testAll({ testAll({ name: "move() a directory with files", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -180,7 +177,7 @@ testAll({ testAll({ name: "move() a directory with dirs", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -197,7 +194,7 @@ testAll({ testAll({ name: "move() should throw when the from path doesn't exist", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -215,7 +212,7 @@ testAll({ testAll({ name: "move() should throw when overwriting an existing file", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -227,7 +224,7 @@ testAll({ testAll({ name: "move() should throw when overwriting an existing directory", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -242,7 +239,7 @@ testAll({ testAll({ name: "move() should not throw when overwriting an existing directory if it's empty", - ignore: [MemoryStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["memory", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/readDir.test.js b/test/unit/studio/src/util/fileSystems/all/tests/readDir.test.js index 9d2ad8ec1..1d30c0202 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/readDir.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/readDir.test.js @@ -92,6 +92,7 @@ testAll({ testAll({ name: "readDir while a new file is being created", + ignore: ["serialized-remote"], // TODO #855 async fn(ctx) { const fs = await ctx.createBasicFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/readFile.test.js b/test/unit/studio/src/util/fileSystems/all/tests/readFile.test.js index 910b951c2..1b8c008f8 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/readFile.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/readFile.test.js @@ -1,5 +1,4 @@ import {assert, assertEquals, assertIsError, assertNotEquals, assertRejects} from "std/testing/asserts.ts"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; import {testAll} from "../shared.js"; testAll({ @@ -50,6 +49,7 @@ testAll({ testAll({ name: "readFile() Two calls at once", + ignore: ["serialized-remote"], // TODO #855 async fn(ctx) { const fs = await ctx.createBasicFs({ disableStructuredClone: true, @@ -107,9 +107,9 @@ testAll({ testAll({ name: "readFile while it is being written", - ignore: [FsaStudioFileSystem], + ignore: ["fsa", "serialized-remote"], // TODO #855 async fn(ctx) { - const fs = await ctx.createBasicFs({disableStructuredClone: true}); + const fs = await ctx.createFs({disableStructuredClone: true}); const promise1 = fs.writeFile(["root", "file"], "hello"); const promise2 = fs.readText(["root", "file"]); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/rootName.test.js b/test/unit/studio/src/util/fileSystems/all/tests/rootName.test.js index fd190bbef..e721475b6 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/rootName.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/rootName.test.js @@ -2,14 +2,11 @@ import {assertEquals} from "std/testing/asserts.ts"; import {assertSpyCall, assertSpyCalls} from "std/testing/mock.ts"; import {testAll} from "../shared.js"; import {registerOnChangeSpy} from "../../shared.js"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {MemoryStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; testAll({ name: "getRootName() should return the value passed in setRootName()", - exclude: [FsaStudioFileSystem], - ignore: [MemoryStudioFileSystem, RemoteStudioFileSystem], + exclude: ["fsa"], + ignore: ["memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createFs(); await fs.setRootName("theRootName"); @@ -22,7 +19,7 @@ testAll({ testAll({ name: "setRootName() should fire onChange event", - exclude: [FsaStudioFileSystem], + exclude: ["fsa"], async fn(ctx) { const fs = await ctx.createFs(); const onChangeSpy = registerOnChangeSpy(fs); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/writeFile.test.js b/test/unit/studio/src/util/fileSystems/all/tests/writeFile.test.js index b74ab2a97..f49685cc2 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/writeFile.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/writeFile.test.js @@ -1,16 +1,86 @@ import {assert, assertEquals, assertRejects} from "std/testing/asserts.ts"; import {assertSpyCall, assertSpyCalls} from "std/testing/mock.ts"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {MemoryStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/MemoryStudioFileSystem.js"; import {assertPromiseResolved} from "../../../../../../shared/asserts.js"; import {waitForMicrotasks} from "../../../../../../shared/waitForMicroTasks.js"; import {registerOnChangeSpy} from "../../shared.js"; -import {IndexedDbStudioFileSystem, testAll} from "../shared.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; +import {testAll} from "../shared.js"; + +/** + * Used for tests that write a single file to root/newfile. + * @param {import("../../../../../../../../studio/src/util/fileSystems/StudioFileSystem.js").StudioFileSystem} fs + * @param {import("../../../../../../../../studio/src/util/fileSystems/StudioFileSystem.js").AllowedWriteFileTypes} fileParam + * @param {number[]} expectedBytes + */ +async function basicWriteFileTest(fs, fileParam, expectedBytes) { + const path = ["root", "newfile"]; + const writeFilePromise = fs.writeFile(path, fileParam); + + // Change the path to verify that the initial array is used + path.push("extra"); + + await writeFilePromise; + + const {files} = await fs.readDir(["root"]); + assert(files.includes("newfile"), "'newfile' was not created"); + + const file = await fs.readFile(["root", "newfile"]); + const buffer = await file.arrayBuffer(); + const view = new Uint8Array(buffer); + assertEquals(Array.from(view), expectedBytes); +} + +testAll({ + name: "writing a File object", + ignore: ["indexedDb"], + async fn(ctx) { + const fs = await ctx.createFs(); + const file = new File([new Uint8Array([1, 2, 3, 4])], "newfile"); + await basicWriteFileTest(fs, file, [1, 2, 3, 4]); + }, +}); + +testAll({ + name: "writing an ArrayBuffer", + ignore: ["indexedDb"], + async fn(ctx) { + const fs = await ctx.createFs(); + const buffer = new Uint8Array([1, 2, 3, 4]).buffer; + await basicWriteFileTest(fs, buffer, [1, 2, 3, 4]); + }, +}); + +testAll({ + name: "writing an Uint8Array", + ignore: ["indexedDb", "fsa"], + async fn(ctx) { + const fs = await ctx.createFs(); + const uint8Array = new Uint8Array([1, 2, 3, 4]); + await basicWriteFileTest(fs, uint8Array, [1, 2, 3, 4]); + }, +}); + +testAll({ + name: "writing a Blob", + ignore: ["indexedDb"], + async fn(ctx) { + const fs = await ctx.createFs(); + const file = new Blob([new Uint8Array([1, 2, 3, 4])]); + await basicWriteFileTest(fs, file, [1, 2, 3, 4]); + }, +}); + +testAll({ + name: "writing a string", + ignore: ["indexedDb"], + async fn(ctx) { + const fs = await ctx.createFs(); + await basicWriteFileTest(fs, "hello", [104, 101, 108, 108, 111]); + }, +}); testAll({ name: "writeFile should create the file and fire onChange", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -42,7 +112,7 @@ testAll({ testAll({ name: "writeFile to existing file should overwrite it and fire change event", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs({disableStructuredClone: true}); const onChangeSpy = registerOnChangeSpy(fs); @@ -74,7 +144,7 @@ testAll({ testAll({ name: "writeFile should create parent directories when they don't exist", - ignore: [RemoteStudioFileSystem], + ignore: ["remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); const onChangeSpy = registerOnChangeSpy(fs); @@ -119,7 +189,7 @@ testAll({ testAll({ name: "writeFile should error when the target is a directory", - ignore: [IndexedDbStudioFileSystem], + ignore: ["indexedDb"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -153,7 +223,7 @@ testAll({ testAll({ name: "writeFile() causes waitForWritesFinish to stay pending until done", - ignore: [FsaStudioFileSystem, MemoryStudioFileSystem, RemoteStudioFileSystem], + ignore: ["fsa", "memory", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); diff --git a/test/unit/studio/src/util/fileSystems/all/tests/writeFileStream.test.js b/test/unit/studio/src/util/fileSystems/all/tests/writeFileStream.test.js index 905e285f3..d706403ba 100644 --- a/test/unit/studio/src/util/fileSystems/all/tests/writeFileStream.test.js +++ b/test/unit/studio/src/util/fileSystems/all/tests/writeFileStream.test.js @@ -1,11 +1,9 @@ import {assert, assertEquals, assertRejects} from "std/testing/asserts.ts"; -import {FsaStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/FsaStudioFileSystem.js"; -import {IndexedDbStudioFileSystem, testAll} from "../shared.js"; -import {RemoteStudioFileSystem} from "../../../../../../../../studio/src/util/fileSystems/RemoteStudioFileSystem.js"; +import {testAll} from "../shared.js"; testAll({ name: "writeFileStream()", - ignore: [IndexedDbStudioFileSystem, FsaStudioFileSystem, RemoteStudioFileSystem], + ignore: ["indexedDb", "fsa", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createFs(); @@ -32,7 +30,7 @@ testAll({ testAll({ name: "writeFileStream should error when the target is a directory", - ignore: [IndexedDbStudioFileSystem, RemoteStudioFileSystem], + ignore: ["indexedDb", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs(); @@ -44,7 +42,7 @@ testAll({ testAll({ name: "writeFileStream should error when a parent is a file", - ignore: [IndexedDbStudioFileSystem, RemoteStudioFileSystem], + ignore: ["indexedDb", "remote", "serialized-remote"], async fn(ctx) { const fs = await ctx.createBasicFs();