/** @file The mock API. */ import * as test from '@playwright/test' import * as backend from '#/services/Backend' import type * as remoteBackend from '#/services/RemoteBackend' import * as remoteBackendPaths from '#/services/remoteBackendPaths' import * as dateTime from '#/utilities/dateTime' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as actions from '.' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const MOCK_SVG = ` ` /** The HTTP status code representing a response with an empty body. */ const HTTP_STATUS_NO_CONTENT = 204 /** The HTTP status code representing a bad request. */ const HTTP_STATUS_BAD_REQUEST = 400 /** The HTTP status code representing a URL that does not exist. */ const HTTP_STATUS_NOT_FOUND = 404 /** A user id that is a path glob. */ const GLOB_USER_ID = backend.UserId('*') /** An asset ID that is a path glob. */ const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*') /** A directory ID that is a path glob. */ const GLOB_DIRECTORY_ID = backend.DirectoryId('*') /** A project ID that is a path glob. */ const GLOB_PROJECT_ID = backend.ProjectId('*') /** A tag ID that is a path glob. */ const GLOB_TAG_ID = backend.TagId('*') /** A checkout session ID that is a path glob. */ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*') const BASE_URL = 'https://mock/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' function array(): Readonly[] { return [] } const INITIAL_CALLS_OBJECT = { changePassword: array<{ oldPassword: string; newPassword: string }>(), listDirectory: array<{ parent_id?: string filter_by?: backend.FilterBy labels?: backend.LabelName[] recent_projects?: boolean }>(), listFiles: array(), listProjects: array(), listSecrets: array(), listTags: array(), listUsers: array(), listUserGroups: array(), getProjectDetails: array<{ projectId: backend.ProjectId }>(), copyAsset: array<{ assetId: backend.AssetId; parentId: backend.DirectoryId }>(), listInvitations: array(), inviteUser: array(), createPermission: array(), closeProject: array<{ projectId: backend.ProjectId }>(), openProject: array<{ projectId: backend.ProjectId }>(), deleteTag: array<{ tagId: backend.TagId }>(), postLogEvent: array(), uploadUserPicture: array<{ content: string }>(), uploadOrganizationPicture: array<{ content: string }>(), s3Put: array(), uploadFileStart: array<{ uploadId: backend.FileId }>(), uploadFileEnd: array(), createSecret: array(), createCheckoutSession: array(), getCheckoutSession: array<{ body: backend.CreateCheckoutSessionRequestBody status: backend.CheckoutSessionStatus }>(), updateAsset: array<{ assetId: backend.AssetId } & backend.UpdateAssetRequestBody>(), associateTag: array<{ assetId: backend.AssetId; labels: readonly backend.LabelName[] }>(), updateDirectory: array< { directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody >(), deleteAsset: array<{ assetId: backend.AssetId }>(), undoDeleteAsset: array<{ assetId: backend.AssetId }>(), createUser: array(), createUserGroup: array(), changeUserGroup: array<{ userId: backend.UserId } & backend.ChangeUserGroupRequestBody>(), updateCurrentUser: array(), usersMe: array(), updateOrganization: array(), getOrganization: array(), createTag: array(), createProject: array(), createDirectory: array(), getProjectContent: array<{ projectId: backend.ProjectId }>(), getProjectAsset: array<{ projectId: backend.ProjectId }>(), } const READONLY_INITIAL_CALLS_OBJECT: TrackedCallsInternal = INITIAL_CALLS_OBJECT export { READONLY_INITIAL_CALLS_OBJECT as INITIAL_CALLS_OBJECT } type TrackedCallsInternal = { [K in keyof typeof INITIAL_CALLS_OBJECT]: Readonly<(typeof INITIAL_CALLS_OBJECT)[K]> } export interface TrackedCalls extends TrackedCallsInternal {} /** Parameters for {@link mockApi}. */ export interface MockParams { readonly page: test.Page readonly setupAPI?: SetupAPI | null | undefined } /** * Setup function for the mock API. * use it to setup the mock API with custom handlers. */ export interface SetupAPI { (api: Awaited>): Promise | void } /** The return type of {@link mockApi}. */ export interface MockApi extends Awaited> {} export const mockApi: (params: MockParams) => Promise = mockApiInternal /** Add route handlers for the mock API to a page. */ async function mockApiInternal({ page, setupAPI }: MockParams) { const defaultEmail = 'email@example.com' as backend.EmailAddress const defaultUsername = 'user name' const defaultPassword = actions.VALID_PASSWORD const defaultOrganizationId = backend.OrganizationId('organization-placeholder id') const defaultOrganizationName = 'organization name' const defaultUserId = backend.UserId('user-placeholder id') const defaultDirectoryId = backend.DirectoryId('directory-placeholder id') const defaultUser: backend.User = { email: defaultEmail, name: defaultUsername, organizationId: defaultOrganizationId, userId: defaultUserId, isEnabled: true, rootDirectoryId: defaultDirectoryId, userGroups: null, plan: backend.Plan.solo, isOrganizationAdmin: true, } const defaultOrganization: backend.OrganizationInfo = { id: defaultOrganizationId, name: defaultOrganizationName, address: null, email: null, picture: null, website: null, subscription: {}, } const callsObjects = new Set() let totalSeats = 1 // eslint-disable-next-line @typescript-eslint/no-unused-vars let subscriptionDuration = 0 let isOnline = true let currentUser: backend.User | null = defaultUser let currentProfilePicture: string | null = null let currentPassword = defaultPassword let currentOrganization: backend.OrganizationInfo | null = defaultOrganization let currentOrganizationProfilePicture: string | null = null const assetMap = new Map() const deletedAssets = new Set() const assets: backend.AnyAsset[] = [] const labels: backend.Label[] = [] const labelsByValue = new Map() const labelMap = new Map() const users: backend.User[] = [defaultUser] const usersMap = new Map() const userGroups: backend.UserGroupInfo[] = [ { id: backend.UserGroupId('usergroup-1'), groupName: 'User Group 1', organizationId: currentOrganization.id, }, ] const checkoutSessionsMap = new Map< backend.CheckoutSessionId, { readonly body: backend.CreateCheckoutSessionRequestBody readonly status: backend.CheckoutSessionStatus } >() usersMap.set(defaultUser.userId, defaultUser) function trackCalls() { const calls = structuredClone(INITIAL_CALLS_OBJECT) callsObjects.add(calls) return calls } function pushToKey, Key extends keyof Object>( object: Object, key: Key, item: Object[Key][number], ) { object[key].push(item) } function called( key: Key, args: (typeof INITIAL_CALLS_OBJECT)[Key][number], ) { for (const callsObject of callsObjects) { pushToKey(callsObject, key, args) } } const addAsset = (asset: T) => { assets.push(asset) assetMap.set(asset.id, asset) return asset } const deleteAsset = (assetId: backend.AssetId) => { const alreadyDeleted = deletedAssets.has(assetId) deletedAssets.add(assetId) return !alreadyDeleted } const undeleteAsset = (assetId: backend.AssetId) => { const wasDeleted = deletedAssets.has(assetId) deletedAssets.delete(assetId) return wasDeleted } const createDirectory = (rest: Partial = {}): backend.DirectoryAsset => { const directoryTitles = new Set( assets .filter((asset) => asset.type === backend.AssetType.directory) .map((asset) => asset.title), ) const title = rest.title ?? `New Folder ${directoryTitles.size + 1}` return object.merge( { type: backend.AssetType.directory, id: backend.DirectoryId('directory-' + uniqueString.uniqueString()), projectState: null, extension: null, title, modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, permissions: [ { user: { organizationId: defaultOrganizationId, userId: defaultUserId, name: defaultUsername, email: defaultEmail, }, permission: permissions.PermissionAction.own, }, ], parentsPath: '', virtualParentsPath: '', }, rest, ) } const createProject = (rest: Partial = {}): backend.ProjectAsset => { const projectNames = new Set( assets .filter((asset) => asset.type === backend.AssetType.project) .map((asset) => asset.title), ) const title = rest.title ?? `New Project ${projectNames.size + 1}` return object.merge( { type: backend.AssetType.project, id: backend.ProjectId('project-' + uniqueString.uniqueString()), projectState: { type: backend.ProjectState.closed, volumeId: '', }, extension: null, title, modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, permissions: [], parentsPath: '', virtualParentsPath: '', }, rest, ) } const createFile = (rest: Partial = {}): backend.FileAsset => object.merge( { type: backend.AssetType.file, id: backend.FileId('file-' + uniqueString.uniqueString()), projectState: null, extension: '', title: rest.title ?? '', modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, permissions: [], parentsPath: '', virtualParentsPath: '', }, rest, ) const createSecret = (rest: Partial): backend.SecretAsset => object.merge( { type: backend.AssetType.secret, id: backend.SecretId('secret-' + uniqueString.uniqueString()), projectState: null, extension: null, title: rest.title ?? '', modifiedAt: dateTime.toRfc3339(new Date()), description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, permissions: [], parentsPath: '', virtualParentsPath: '', }, rest, ) const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ id: backend.TagId('tag-' + uniqueString.uniqueString()), value: backend.LabelName(value), color, }) const addDirectory = (rest: Partial) => { return addAsset(createDirectory(rest)) } const addProject = (rest: Partial) => { return addAsset(createProject(rest)) } const addFile = (rest: Partial) => { return addAsset(createFile(rest)) } const addSecret = (rest: Partial) => { return addAsset(createSecret(rest)) } const addLabel = (value: string, color: backend.LChColor) => { const label = createLabel(value, color) labels.push(label) labelsByValue.set(label.value, label) labelMap.set(label.id, label) return label } const setLabels = (id: backend.AssetId, newLabels: readonly backend.LabelName[]) => { const ids = new Set([id]) for (const [innerId, asset] of assetMap) { if (ids.has(asset.parentId)) { ids.add(innerId) } } for (const innerId of ids) { const asset = assetMap.get(innerId) if (asset != null) { object.unsafeMutable(asset).labels = newLabels } } } const createCheckoutSession = ( body: backend.CreateCheckoutSessionRequestBody, rest: Partial = {}, ) => { const id = backend.CheckoutSessionId(`checkoutsession-${uniqueString.uniqueString()}`) const status = rest.status ?? 'trialing' const paymentStatus = status === 'trialing' ? 'no_payment_needed' : 'unpaid' const checkoutSessionStatus = { status, paymentStatus, ...rest, } satisfies backend.CheckoutSessionStatus checkoutSessionsMap.set(id, { body, status: checkoutSessionStatus }) return { id, clientSecret: '', } satisfies backend.CheckoutSession } const addUser = (name: string, rest: Partial = {}) => { const organizationId = currentOrganization?.id ?? defaultOrganizationId const user: backend.User = { userId: backend.UserId(`user-${uniqueString.uniqueString()}`), name, email: backend.EmailAddress(`${name}@example.org`), organizationId, rootDirectoryId: backend.DirectoryId(organizationId.replace(/^organization-/, 'directory-')), isEnabled: true, userGroups: null, plan: backend.Plan.enterprise, isOrganizationAdmin: true, ...rest, } users.push(user) usersMap.set(user.userId, user) return user } const deleteUser = (userId: backend.UserId) => { usersMap.delete(userId) const index = users.findIndex((user) => user.userId === userId) if (index === -1) { return false } else { users.splice(index, 1) return true } } const addUserGroup = (name: string, rest?: Partial) => { const userGroup: backend.UserGroupInfo = { id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}`), groupName: name, organizationId: currentOrganization?.id ?? defaultOrganizationId, ...rest, } userGroups.push(userGroup) return userGroup } const deleteUserGroup = (userGroupId: backend.UserGroupId) => { const index = userGroups.findIndex((userGroup) => userGroup.id === userGroupId) if (index === -1) { return false } else { users.splice(index, 1) return true } } // FIXME[sb]: Add missing endpoints: // addPermission, // deletePermission, const addUserGroupToUser = (userId: backend.UserId, userGroupId: backend.UserGroupId) => { const user = usersMap.get(userId) if (user == null || user.userGroups?.includes(userGroupId) === true) { // The user does not exist, or they are already in this group. return false } else { const newUserGroups = object.unsafeMutable(user.userGroups ?? []) newUserGroups.push(userGroupId) object.unsafeMutable(user).userGroups = newUserGroups return true } } const removeUserGroupFromUser = (userId: backend.UserId, userGroupId: backend.UserGroupId) => { const user = usersMap.get(userId) if (user?.userGroups?.includes(userGroupId) !== true) { // The user does not exist, or they are already not in this group. return false } else { object.unsafeMutable(user.userGroups).splice(user.userGroups.indexOf(userGroupId), 1) return true } } await test.test.step('Mock API', async () => { const method = (theMethod: string) => async (url: string, callback: (route: test.Route, request: test.Request) => unknown) => { await page.route(BASE_URL + url, async (route, request) => { if (request.method() !== theMethod) { await route.fallback() } else { const result = await callback(route, request) // `null` counts as a JSON value that we will want to return. if (result !== undefined) { await route.fulfill({ json: result }) } } }) } const get = method('GET') const put = method('PUT') const post = method('POST') const patch = method('PATCH') const delete_ = method('DELETE') await page.route(BASE_URL + '**', (_route, request) => { throw new Error( `Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`, ) }) // === Mock Cognito endpoints === await page.route('https://mock-cognito.com/change-password', async (route, request) => { if (request.method() !== 'POST') { await route.fallback() } else { /** The type for the JSON request payload for this endpoint. */ interface Body { readonly oldPassword: string readonly newPassword: string } const body: Body = await request.postDataJSON() called('changePassword', body) if (body.oldPassword === currentPassword) { currentPassword = body.newPassword await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) } else { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) } } }) // === Endpoints returning arrays === await get(remoteBackendPaths.LIST_DIRECTORY_PATH + '*', (_route, request) => { /** The type for the search query for this endpoint. */ interface Query { readonly parent_id?: string readonly filter_by?: backend.FilterBy readonly labels?: backend.LabelName[] readonly recent_projects?: boolean } const query = Object.fromEntries( new URL(request.url()).searchParams.entries(), ) as unknown as Query called('listDirectory', query) const parentId = query.parent_id ?? defaultDirectoryId let filteredAssets = assets.filter((asset) => asset.parentId === parentId) // This lint rule is broken; there is clearly a case for `undefined` below. switch (query.filter_by) { case backend.FilterBy.active: { filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id)) break } case backend.FilterBy.trashed: { filteredAssets = filteredAssets.filter((asset) => deletedAssets.has(asset.id)) break } case backend.FilterBy.recent: { filteredAssets = assets.filter((asset) => !deletedAssets.has(asset.id)).slice(0, 10) break } case backend.FilterBy.all: case null: { // do nothing break } case undefined: { // do nothing break } } filteredAssets.sort( (a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type], ) const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets } return json }) await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { called('listFiles', {}) return { files: [] } satisfies remoteBackend.ListFilesResponseBody }) await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => { called('listProjects', {}) return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody }) await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => { called('listSecrets', {}) return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody }) await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => { called('listTags', {}) return { tags: labels } satisfies remoteBackend.ListTagsResponseBody }) await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { called('listUsers', {}) if (currentUser != null) { return { users } satisfies remoteBackend.ListUsersResponseBody } else { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) return } }) await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => { called('listUserGroups', {}) await route.fulfill({ json: userGroups }) }) // === Endpoints with dummy implementations === await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('getProjectDetails', { projectId }) const project = assetMap.get(projectId) if (!project) { throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it. ------------------------------------------------------------------------------------------------ Existing projects: ${Array.from(assetMap.values()) .filter((asset) => asset.type === backend.AssetType.project) .map((asset) => asset.id) .join(', ')}`) } if (!project.projectState) { throw new Error(`Attempting to get a project that does not have a state. Usually it is a bug in the application. ------------------------------------------------------------------------------------------------ Tried to get: \n ${JSON.stringify(project, null, 2)}`) } return { organizationId: defaultOrganizationId, projectId: projectId, name: 'example project name', state: project.projectState, packageName: 'Project_root', // eslint-disable-next-line camelcase ide_version: null, // eslint-disable-next-line camelcase engine_version: { value: '2023.2.1-nightly.2023.9.29', lifecycle: backend.VersionLifecycle.development, }, address: backend.Address('ws://localhost/'), } satisfies backend.ProjectRaw }) // === Endpoints returning `void` === await post(remoteBackendPaths.copyAssetPath(GLOB_ASSET_ID), async (route, request) => { /** The type for the JSON request payload for this endpoint. */ interface Body { readonly parentDirectoryId: backend.DirectoryId } const maybeId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] if (!maybeId) return const assetId = maybeId != null ? backend.DirectoryId(decodeURIComponent(maybeId)) : null // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const asset = assetId != null ? assetMap.get(assetId) : null if (asset == null) { if (assetId == null) { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, json: { message: 'Invalid Asset ID' }, }) } else { await route.fulfill({ status: HTTP_STATUS_NOT_FOUND, json: { message: 'Asset does not exist' }, }) } } else { const body: Body = request.postDataJSON() const parentId = body.parentDirectoryId called('copyAsset', { assetId: assetId!, parentId }) // Can be any asset ID. const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`) const json: backend.CopyAssetResponse = { asset: { id, parentId, title: asset.title + ' (copy)', }, } const newAsset = { ...asset } newAsset.id = id newAsset.parentId = parentId newAsset.title += ' (copy)' addAsset(newAsset) return json } }) await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => { called('listInvitations', {}) return { invitations: [], availableLicenses: totalSeats - usersMap.size, } }) await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => { called('inviteUser', {}) await route.fulfill() }) await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => { called('createPermission', {}) await route.fulfill() }) await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('closeProject', { projectId }) const project = assetMap.get(projectId) if (project?.projectState) { object.unsafeMutable(project.projectState).type = backend.ProjectState.closed } await route.fulfill() }) await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('openProject', { projectId }) const project = assetMap.get(projectId) if (!project) { throw new Error( `Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`, ) } if (project?.projectState) { object.unsafeMutable(project.projectState).type = backend.ProjectState.opened } route.fulfill() }) await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const tagId = backend.TagId(maybeId) called('deleteTag', { tagId }) await route.fulfill() }) await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => { called('postLogEvent', {}) await route.fulfill() }) // === Entity creation endpoints === await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { called('uploadUserPicture', { content }) currentProfilePicture = content return null } else { await route.fallback() return } }) await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { called('uploadOrganizationPicture', { content }) currentOrganizationProfilePicture = content return null } else { await route.fallback() return } }) await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => { if (request.method() !== 'PUT') { called('s3Put', {}) await route.fallback() } else { await route.fulfill({ headers: { 'Access-Control-Expose-Headers': 'ETag', ETag: uniqueString.uniqueString(), }, }) } }) await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => { const uploadId = backend.FileId('file-' + uniqueString.uniqueString()) called('uploadFileStart', { uploadId }) return { sourcePath: backend.S3FilePath(''), uploadId, presignedUrls: Array.from({ length: 10 }, () => backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`), ), } satisfies backend.UploadLargeFileMetadata }) await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => { const body: backend.UploadFileEndRequestBody = request.postDataJSON() called('uploadFileEnd', body) const file = addFile({ id: backend.FileId(body.uploadId), title: body.fileName, ...(body.parentDirectoryId != null ? { parentId: body.parentDirectoryId } : {}), }) return { id: file.id, project: null } satisfies backend.UploadedLargeAsset }) await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { const body: backend.CreateSecretRequestBody = await request.postDataJSON() called('createSecret', body) const secret = addSecret({ title: body.name }) return secret.id }) // === Other endpoints === await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => { const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON() called('createCheckoutSession', body) return createCheckoutSession(body) }) await get( remoteBackendPaths.getCheckoutSessionPath(GLOB_CHECKOUT_SESSION_ID) + '*', (_route, request) => { const checkoutSessionId = request.url().match(/[/]payments[/]subscriptions[/]([^/?]+)/)?.[1] if (checkoutSessionId == null) { throw new Error('GetCheckoutSession: Missing checkout session ID in path') } else { const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId)) if (result) { called('getCheckoutSession', result) if (currentUser) { object.unsafeMutable(currentUser).plan = result.body.plan } totalSeats = result.body.quantity subscriptionDuration = result.body.interval return result.status } else { throw new Error('GetCheckoutSession: Unknown checkout session ID') } } }, ) await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => { const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] if (!maybeId) return // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const assetId = backend.DirectoryId(maybeId) const body: backend.UpdateAssetRequestBody = request.postDataJSON() called('updateAsset', { ...body, assetId }) const asset = assetMap.get(assetId) if (asset != null) { if (body.description != null) { object.unsafeMutable(asset).description = body.description } if (body.parentDirectoryId != null) { object.unsafeMutable(asset).parentId = body.parentDirectoryId } } }) await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { const maybeId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] if (!maybeId) return // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const assetId = backend.DirectoryId(maybeId) /** The type for the JSON request payload for this endpoint. */ interface Body { readonly labels: readonly backend.LabelName[] } /** The type for the JSON response payload for this endpoint. */ interface Response { readonly tags: readonly backend.Label[] } const body: Body = await request.postDataJSON() called('associateTag', { ...body, assetId }) setLabels(assetId, body.labels) const json: Response = { tags: body.labels.flatMap((value) => { const label = labelsByValue.get(value) return label != null ? [label] : [] }), } return json }) await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] if (!maybeId) return const directoryId = backend.DirectoryId(maybeId) const body: backend.UpdateDirectoryRequestBody = request.postDataJSON() called('updateDirectory', { ...body, directoryId }) const asset = assetMap.get(directoryId) if (asset == null) { await route.abort() } else { object.unsafeMutable(asset).title = body.title await route.fulfill({ json: { id: directoryId, parentId: asset.parentId, title: body.title, } satisfies backend.UpdatedDirectory, }) } }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] if (!maybeId) return // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const assetId = backend.DirectoryId(decodeURIComponent(maybeId)) called('deleteAsset', { assetId }) deleteAsset(assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { /** The type for the JSON request payload for this endpoint. */ interface Body { readonly assetId: backend.AssetId } const body: Body = await request.postDataJSON() called('undoDeleteAsset', body) undeleteAsset(body.assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (_route, request) => { const body: backend.CreateUserRequestBody = await request.postDataJSON() const organizationId = body.organizationId ?? defaultUser.organizationId const rootDirectoryId = backend.DirectoryId( organizationId.replace(/^organization-/, 'directory-'), ) called('createUser', body) currentUser = { email: body.userEmail, name: body.userName, organizationId, userId: backend.UserId(`user-${uniqueString.uniqueString()}`), isEnabled: true, rootDirectoryId, userGroups: null, isOrganizationAdmin: true, } return currentUser }) await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => { const body: backend.CreateUserGroupRequestBody = await request.postDataJSON() called('createUserGroup', body) const userGroup = addUserGroup(body.name) return userGroup }) await put( remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*', async (route, request) => { const maybeId = request.url().match(/[/]users[/]([^?/]+)/)?.[1] if (!maybeId) return const userId = backend.UserId(decodeURIComponent(maybeId)) // The type of the body sent by this app is statically known. const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON() called('changeUserGroup', { userId, ...body }) const user = usersMap.get(userId) if (!user) { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) } else { object.unsafeMutable(user).userGroups = body.userGroups return user } }, ) await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => { const body: backend.UpdateUserRequestBody = await request.postDataJSON() called('updateCurrentUser', body) if (currentUser && body.username != null) { currentUser = { ...currentUser, name: body.username } } }) await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => { called('usersMe', {}) if (currentUser == null) { return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) } else { return currentUser } }) await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON() called('updateOrganization', body) if (body.name === '') { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, json: { message: 'Organization name must not be empty' }, }) return } else if (currentOrganization) { currentOrganization = { ...currentOrganization, ...body } return currentOrganization satisfies backend.OrganizationInfo } else { await route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) return } }) await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => { called('getOrganization', {}) await route.fulfill({ json: currentOrganization, status: currentOrganization == null ? 404 : 200, }) }) await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => { const body: backend.CreateTagRequestBody = route.request().postDataJSON() called('createTag', body) return addLabel(body.value, body.color) }) await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => { const body: backend.CreateProjectRequestBody = request.postDataJSON() called('createProject', body) const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const parentId = body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const state = { type: backend.ProjectState.closed, volumeId: '' } const project = addProject({ description: null, id, labels: [], modifiedAt: dateTime.toRfc3339(new Date()), parentId, permissions: [ { user: { organizationId: defaultOrganizationId, userId: defaultUserId, name: defaultUsername, email: defaultEmail, }, permission: permissions.PermissionAction.own, }, ], projectState: state, }) return { title: project.title, id: project.id, parentId: project.parentId, state: project.projectState, organizationId: defaultOrganizationId, packageName: 'Project_root', projectId: id, } }) await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => { const body: backend.CreateDirectoryRequestBody = request.postDataJSON() called('createDirectory', body) const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const parentId = body.parentId ?? defaultDirectoryId const directory = addDirectory({ description: null, id, labels: [], modifiedAt: dateTime.toRfc3339(new Date()), parentId, permissions: [ { user: { organizationId: defaultOrganizationId, userId: defaultUserId, name: defaultUsername, email: defaultEmail, }, permission: permissions.PermissionAction.own, }, ], projectState: null, }) return { title: directory.title, id: directory.id, parentId: directory.parentId, } }) await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('getProjectContent', { projectId }) const content = readFileSync(join(__dirname, '../mock/enso-demo.main'), 'utf8') return route.fulfill({ body: content, contentType: 'text/plain', }) }) await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route, request) => { const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('getProjectAsset', { projectId }) return route.fulfill({ // This is a mock SVG image. Just a square with a black background. body: '/mock/svg.svg', contentType: 'text/plain', }) }) await page.route('mock/svg.svg', (route) => { return route.fulfill({ body: MOCK_SVG, contentType: 'image/svg+xml' }) }) await page.route('**/assets/*.svg', (route) => { return route.fulfill({ body: MOCK_SVG, contentType: 'image/svg+xml' }) }) await page.route('*', async (route) => { if (!isOnline) { await route.abort('connectionfailed') } }) }) const api = { defaultEmail, defaultName: defaultUsername, defaultOrganization, defaultOrganizationId, defaultOrganizationName, defaultUser, defaultUserId, rootDirectoryId: defaultDirectoryId, goOffline: () => { isOnline = false }, goOnline: () => { isOnline = true }, setPlan: (plan: backend.Plan) => { if (currentUser) { object.unsafeMutable(currentUser).plan = plan } }, currentUser: () => currentUser, setCurrentUser: (user: backend.User | null) => { currentUser = user }, currentPassword: () => currentPassword, currentProfilePicture: () => currentProfilePicture, currentOrganization: () => currentOrganization, setCurrentOrganization: (organization: backend.OrganizationInfo | null) => { currentOrganization = organization }, currentOrganizationProfilePicture: () => currentOrganizationProfilePicture, addAsset, deleteAsset, undeleteAsset, createDirectory, createProject, createFile, createSecret, addDirectory, addProject, addFile, addSecret, createLabel, addLabel, setLabels, createCheckoutSession, addUser, deleteUser, addUserGroup, deleteUserGroup, // TODO: // addPermission, // deletePermission, addUserGroupToUser, removeUserGroupFromUser, trackCalls, } as const if (setupAPI) { await setupAPI(api) } return api }