diff --git a/.gitattributes b/.gitattributes index fcadb2cf97..d0c0c4c1dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text eol=lf +*.png binary diff --git a/.prettierignore b/.prettierignore index b4299bd8fb..5e2f83632c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -28,6 +28,8 @@ test/**/data # Generated files app/ide-desktop/lib/client/electron-builder-config.json app/ide-desktop/lib/content-config/src/config.json +app/ide-desktop/lib/dashboard/playwright-report/ +app/ide-desktop/lib/dashboard/playwright/.cache/ app/gui/view/documentation/assets/stylesheet.css app/gui2/rust-ffi/pkg Cargo.lock diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 394e6213c3..57a267f3f3 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -30,7 +30,7 @@ const NAME = 'enso' * `yargs` is a modules we explicitly want the default imports of. * `node:process` is here because `process.on` does not exist on the namespace import. */ const DEFAULT_IMPORT_ONLY_MODULES = - 'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*' + 'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml' const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss` const OUR_MODULES = 'enso-.*' const RELATIVE_MODULES = @@ -244,6 +244,10 @@ const RESTRICTED_SYNTAXES = [ /* eslint-disable @typescript-eslint/naming-convention */ export default [ eslintJs.configs.recommended, + { + // Playwright build cache. + ignores: ['**/.cache/**'], + }, { settings: { react: { @@ -314,7 +318,7 @@ export default [ 'react-hooks/exhaustive-deps': 'error', // Prefer `interface` over `type`. '@typescript-eslint/consistent-type-definitions': 'error', - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'no-type-imports' }], + '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/member-ordering': 'error', // Method syntax is not type-safe. // See: https://typescript-eslint.io/rules/method-signature-style @@ -388,7 +392,7 @@ export default [ '@typescript-eslint/no-magic-numbers': [ 'error', { - ignore: [-1, 0, 1, 2], + ignore: [-1, 0, 1, 2, 3, 4, 5], ignoreArrayIndexes: true, ignoreEnums: true, detectObjects: true, @@ -399,46 +403,46 @@ export default [ // Important to warn on accidental duplicated `interface`s e.g. when writing API wrappers. '@typescript-eslint/no-redeclare': ['error', { ignoreDeclarationMerge: false }], 'no-shadow': 'off', - '@typescript-eslint/no-shadow': 'warn', + '@typescript-eslint/no-shadow': 'error', 'no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'error', 'jsdoc/require-param-type': 'off', - 'jsdoc/check-access': 'warn', - 'jsdoc/check-alignment': 'warn', - 'jsdoc/check-indentation': 'warn', - 'jsdoc/check-line-alignment': 'warn', - 'jsdoc/check-param-names': 'warn', - 'jsdoc/check-property-names': 'warn', - 'jsdoc/check-syntax': 'warn', - 'jsdoc/check-tag-names': 'warn', - 'jsdoc/check-types': 'warn', - 'jsdoc/check-values': 'warn', - 'jsdoc/empty-tags': 'warn', - 'jsdoc/implements-on-classes': 'warn', - 'jsdoc/no-bad-blocks': 'warn', - 'jsdoc/no-defaults': 'warn', - 'jsdoc/no-multi-asterisks': 'warn', - 'jsdoc/no-types': 'warn', - 'jsdoc/no-undefined-types': 'warn', - 'jsdoc/require-asterisk-prefix': 'warn', - 'jsdoc/require-description': 'warn', + 'jsdoc/check-access': 'error', + 'jsdoc/check-alignment': 'error', + 'jsdoc/check-indentation': 'error', + 'jsdoc/check-line-alignment': 'error', + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-property-names': 'error', + 'jsdoc/check-syntax': 'error', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/check-types': 'error', + 'jsdoc/check-values': 'error', + 'jsdoc/empty-tags': 'error', + 'jsdoc/implements-on-classes': 'error', + 'jsdoc/no-bad-blocks': 'error', + 'jsdoc/no-defaults': 'error', + 'jsdoc/no-multi-asterisks': 'error', + 'jsdoc/no-types': 'error', + 'jsdoc/no-undefined-types': 'error', + 'jsdoc/require-asterisk-prefix': 'error', + 'jsdoc/require-description': 'error', // This rule does not handle `# Heading`s and "etc.", "e.g.", "vs." etc. - // 'jsdoc/require-description-complete-sentence': 'warn', - 'jsdoc/require-file-overview': 'warn', - 'jsdoc/require-hyphen-before-param-description': 'warn', - 'jsdoc/require-param-description': 'warn', - 'jsdoc/require-param-name': 'warn', - 'jsdoc/require-property': 'warn', - 'jsdoc/require-property-description': 'warn', - 'jsdoc/require-property-name': 'warn', - 'jsdoc/require-property-type': 'warn', - 'jsdoc/require-returns-check': 'warn', - 'jsdoc/require-returns-description': 'warn', - 'jsdoc/require-throws': 'warn', - 'jsdoc/require-yields': 'warn', - 'jsdoc/require-yields-check': 'warn', - 'jsdoc/tag-lines': 'warn', - 'jsdoc/valid-types': 'warn', + // 'jsdoc/require-description-complete-sentence': 'error', + 'jsdoc/require-file-overview': 'error', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-param-description': 'error', + 'jsdoc/require-param-name': 'error', + 'jsdoc/require-property': 'error', + 'jsdoc/require-property-description': 'error', + 'jsdoc/require-property-name': 'error', + 'jsdoc/require-property-type': 'error', + 'jsdoc/require-returns-check': 'error', + 'jsdoc/require-returns-description': 'error', + 'jsdoc/require-throws': 'error', + 'jsdoc/require-yields': 'error', + 'jsdoc/require-yields-check': 'error', + 'jsdoc/tag-lines': 'error', + 'jsdoc/valid-types': 'error', }, }, { @@ -478,6 +482,12 @@ export default [ 'lib/dashboard/src/**/*.tsx', 'lib/dashboard/src/**/*.mtsx', 'lib/dashboard/src/**/*.ctsx', + 'lib/dashboard/mock/**/*.ts', + 'lib/dashboard/mock/**/*.mts', + 'lib/dashboard/mock/**/*.cts', + 'lib/dashboard/mock/**/*.tsx', + 'lib/dashboard/mock/**/*.mtsx', + 'lib/dashboard/mock/**/*.ctsx', ], rules: { 'no-restricted-properties': [ @@ -513,6 +523,69 @@ export default [ ], }, }, + { + files: [ + 'lib/dashboard/test*/**/*.ts', + 'lib/dashboard/test*/**/*.mts', + 'lib/dashboard/test*/**/*.cts', + 'lib/dashboard/test*/**/*.tsx', + 'lib/dashboard/test*/**/*.mtsx', + 'lib/dashboard/test*/**/*.ctsx', + ], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'console', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugState', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugEffect', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugMemo', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugCallback', + message: 'Avoid leaving debugging statements when committing code', + }, + { + property: '$d$', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'page', + property: 'type', + message: 'Prefer `locator.type` instead', + }, + { + object: 'page', + property: 'click', + message: 'Prefer `locator.click` instead', + }, + { + object: 'page', + property: 'fill', + message: 'Prefer `locator.fill` instead', + }, + { + object: 'page', + property: 'locator', + message: 'Prefer `page.getBy*` instead', + }, + ], + }, + }, { files: ['**/*.d.ts'], rules: { diff --git a/app/ide-desktop/lib/client/electron-builder-config.ts b/app/ide-desktop/lib/client/electron-builder-config.ts index c000af77ed..91ba3dca27 100644 --- a/app/ide-desktop/lib/client/electron-builder-config.ts +++ b/app/ide-desktop/lib/client/electron-builder-config.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs/promises' import * as electronBuilder from 'electron-builder' import * as electronNotarize from 'electron-notarize' -import * as macOptions from 'app-builder-lib/out/options/macOptions' +import type * as macOptions from 'app-builder-lib/out/options/macOptions' import yargs from 'yargs' import * as common from 'enso-common' diff --git a/app/ide-desktop/lib/client/esbuild-config.ts b/app/ide-desktop/lib/client/esbuild-config.ts index aa4ac2c3d7..1ad89e1def 100644 --- a/app/ide-desktop/lib/client/esbuild-config.ts +++ b/app/ide-desktop/lib/client/esbuild-config.ts @@ -1,7 +1,7 @@ /** @file Esbuild config file. */ import * as path from 'node:path' -import * as esbuild from 'esbuild' +import type * as esbuild from 'esbuild' import esbuildPluginYaml from 'esbuild-plugin-yaml' import * as paths from './paths' diff --git a/app/ide-desktop/lib/client/src/bin/project-manager.ts b/app/ide-desktop/lib/client/src/bin/project-manager.ts index dfb5169c26..010086ca78 100644 --- a/app/ide-desktop/lib/client/src/bin/project-manager.ts +++ b/app/ide-desktop/lib/client/src/bin/project-manager.ts @@ -6,7 +6,7 @@ import * as util from 'node:util' import * as contentConfig from 'enso-content-config' -import * as config from 'config' +import type * as config from 'config' const logger = contentConfig.logger // This is a wrapped function, so it should be `camelCase`. diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts index 4ffd3d6a8c..bc089d9468 100644 --- a/app/ide-desktop/lib/client/src/bin/server.ts +++ b/app/ide-desktop/lib/client/src/bin/server.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs' import * as http from 'node:http' import * as path from 'node:path' -import * as stream from 'node:stream' +import type * as stream from 'node:stream' import * as mime from 'mime-types' import * as portfinder from 'portfinder' diff --git a/app/ide-desktop/lib/client/src/config/parser.ts b/app/ide-desktop/lib/client/src/config/parser.ts index d3e1c6d967..be311734f0 100644 --- a/app/ide-desktop/lib/client/src/config/parser.ts +++ b/app/ide-desktop/lib/client/src/config/parser.ts @@ -4,7 +4,7 @@ import chalk from 'chalk' import stringLength from 'string-length' // eslint-disable-next-line no-restricted-syntax -import yargs, { Options } from 'yargs' +import yargs, { type Options } from 'yargs' import * as contentConfig from 'enso-content-config' diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts index 87fe732d76..fbaac1d646 100644 --- a/app/ide-desktop/lib/client/src/file-associations.ts +++ b/app/ide-desktop/lib/client/src/file-associations.ts @@ -16,7 +16,7 @@ import electronIsDev from 'electron-is-dev' import * as common from 'enso-common' import * as contentConfig from 'enso-content-config' -import * as clientConfig from './config' +import type * as clientConfig from './config' import * as fileAssociations from '../file-associations' import * as project from './project-management' diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index 595ad67ee7..5d132d62c2 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -11,7 +11,7 @@ import * as crypto from 'node:crypto' import * as fs from 'node:fs' import * as pathModule from 'node:path' -import * as stream from 'node:stream' +import type * as stream from 'node:stream' import * as electron from 'electron' import * as tar from 'tar' diff --git a/app/ide-desktop/lib/client/watch.ts b/app/ide-desktop/lib/client/watch.ts index dcce5893e5..e7a0cf95ad 100644 --- a/app/ide-desktop/lib/client/watch.ts +++ b/app/ide-desktop/lib/client/watch.ts @@ -114,7 +114,8 @@ const ALL_BUNDLES_READY = new Promise((resolve, reject) => { path.resolve(THIS_PATH, '..', '..', 'debugGlobals.ts') ) contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets') - contentOpts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') + contentOpts.define['process.env.REDIRECT_OVERRIDE'] = + JSON.stringify('http://localhost:8080') const contentBuilder = await esbuild.context(contentOpts) const content = await contentBuilder.rebuild() console.log('Result of content bundling: ', content) diff --git a/app/ide-desktop/lib/common/src/detect.ts b/app/ide-desktop/lib/common/src/detect.ts index d17b3af855..fee97766b1 100644 --- a/app/ide-desktop/lib/common/src/detect.ts +++ b/app/ide-desktop/lib/common/src/detect.ts @@ -1,5 +1,12 @@ /** @file Helper functions for environment detection. */ +// =================== +// === IS_DEV_MODE === +// =================== + +/** Return whether the current build is in development mode */ +export const IS_DEV_MODE = process.env.NODE_ENV === 'development' + // ================ // === Platform === // ================ diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index 3a10a3e566..54b386f3c2 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -13,7 +13,7 @@ import * as fsSync from 'node:fs' import * as pathModule from 'node:path' import * as url from 'node:url' -import * as esbuild from 'esbuild' +import type * as esbuild from 'esbuild' import * as esbuildPluginNodeGlobals from '@esbuild-plugins/node-globals-polyfill' import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill' import esbuildPluginCopyDirectories from 'esbuild-plugin-copy-directories' @@ -159,7 +159,7 @@ export function bundlerOptions(args: Arguments) { BUILD_INFO: JSON.stringify(BUILD_INFO), /** Whether the application is being run locally. This enables a service worker that * properly serves `/index.html` to client-side routes like `/login`. */ - IS_DEV_MODE: JSON.stringify(devMode), + 'process.env.NODE_ENV': JSON.stringify(devMode ? 'development' : 'production'), /** Overrides the redirect URL for OAuth logins in the production environment. * This is needed for logins to work correctly under `./run gui watch`. */ REDIRECT_OVERRIDE: 'undefined', diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 1598389f1f..a750fa143a 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -40,7 +40,7 @@ const FETCH_TIMEOUT = 300 // === Live reload === // =================== -if (IS_DEV_MODE && !detect.isOnElectron()) { +if (detect.IS_DEV_MODE && !detect.isOnElectron()) { new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => { // This acts like `location.reload`, but it preserves the query-string. // The `toString()` is to bypass a lint without using a comment. diff --git a/app/ide-desktop/lib/content/watch.ts b/app/ide-desktop/lib/content/watch.ts index f79253fdbd..1cb16287d3 100644 --- a/app/ide-desktop/lib/content/watch.ts +++ b/app/ide-desktop/lib/content/watch.ts @@ -40,7 +40,7 @@ async function watch() { ) opts.pure.splice(opts.pure.indexOf('assert'), 1) ;(opts.inject = opts.inject ?? []).push(path.resolve(THIS_PATH, '..', '..', 'debugGlobals.ts')) - opts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') + opts.define['process.env.REDIRECT_OVERRIDE'] = JSON.stringify('http://localhost:8080') // This is safe as this entry point is statically known. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const serviceWorkerEntryPoint = opts.entryPoints.find( diff --git a/app/ide-desktop/lib/dashboard/.gitignore b/app/ide-desktop/lib/dashboard/.gitignore new file mode 100644 index 0000000000..75e854d8dc --- /dev/null +++ b/app/ide-desktop/lib/dashboard/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/app/ide-desktop/lib/dashboard/esbuild-config.ts b/app/ide-desktop/lib/dashboard/esbuild-config.ts index e818982283..1c1cfdaca3 100644 --- a/app/ide-desktop/lib/dashboard/esbuild-config.ts +++ b/app/ide-desktop/lib/dashboard/esbuild-config.ts @@ -10,7 +10,7 @@ import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as url from 'node:url' -import * as esbuild from 'esbuild' +import type * as esbuild from 'esbuild' import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill' import esbuildPluginInlineImage from 'esbuild-plugin-inline-image' import esbuildPluginTime from 'esbuild-plugin-time' @@ -120,7 +120,7 @@ export function bundlerOptions(args: Arguments) { /* eslint-disable @typescript-eslint/naming-convention */ /** Whether the application is being run locally. This enables a service worker that * properly serves `/index.html` to client-side routes like `/login`. */ - IS_DEV_MODE: JSON.stringify(devMode), + 'process.env.NODE_ENV': JSON.stringify(devMode ? 'development' : 'production'), /** Overrides the redirect URL for OAuth logins in the production environment. * This is needed for logins to work correctly under `./run gui watch`. */ REDIRECT_OVERRIDE: 'undefined', diff --git a/app/ide-desktop/lib/dashboard/log-screenshot-diffs.ts b/app/ide-desktop/lib/dashboard/log-screenshot-diffs.ts new file mode 100644 index 0000000000..9ed4dfc873 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/log-screenshot-diffs.ts @@ -0,0 +1,15 @@ +/** @file Temporary file to print base64 of a png file. */ +import * as fs from 'node:fs/promises' + +const ROOT = './test-results/' + +for (const childName of await fs.readdir(ROOT)) { + const childPath = ROOT + childName + for (const fileName of await fs.readdir(childPath)) { + const filePath = childPath + '/' + fileName + const file = await fs.readFile(filePath) + console.log(filePath, file.toString('base64')) + } +} + +process.exit(1) diff --git a/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/cognito.ts new file mode 100644 index 0000000000..18f9c8e4b1 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/cognito.ts @@ -0,0 +1,364 @@ +/** @file Provides {@link Cognito} class which is the entrypoint into the AWS Amplify library. + * + * All of the functions used for authentication are provided by the AWS Amplify library, but we + * provide a thin wrapper around them to make them easier to use. Mainly, we perform some error + * handling and conditional logic to vary behavior between desktop & cloud. + * + * # Error Handling + * + * The AWS Amplify library throws errors when authentication fails. We catch these errors and + * convert them to typed responses. This allows us to exhaustively handle errors by providing + * information on the types of errors returned, in function return types. + * + * Not all errors are caught and handled. Any errors not relevant to business logic or control flow + * are allowed to propagate up. + * + * Errors are grouped by the AWS Amplify function that throws the error (e.g., `signUp`). This is + * because the Amplify library reuses some error codes for multiple kinds of errors. For example, + * the `UsernameExistsException` error code is used for both the `signUp` and `confirmSignUp` + * functions. This would be fine if the same error code didn't meet different conditions for each + * + * Each error must provide a way to disambiguate from other errors. Typically, our error definitions + * include an `internalCode` field, which is the code that the Amplify library uses to identify the + * error. + * + * Some errors also include an `internalMessage` field, which is the message that the Amplify + * library associates with the error. This field is used to distinguish between errors that have the + * same `internalCode`. + * + * Amplify reuses some codes for multiple kinds of errors. In the case of ambiguous errors, the + * `kind` field provides a unique string that can be used to brand the error in place of the + * `internalCode`, when rethrowing the error. */ +// These SHOULD NOT import any runtime code. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type * as amplify from '@aws-amplify/auth' +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type * as cognito from 'amazon-cognito-identity-js' +import * as results from 'ts-results' + +import type * as config from '../../../../src/authentication/src/authentication/config' +import type * as loggerProvider from '../../../../src/authentication/src/providers/logger' +import * as original from '../../../../src/authentication/src/authentication/cognito' + +// This file exports a subset of the values from the original file. +/* eslint-disable no-restricted-syntax */ +export { + ConfirmSignUpErrorKind, + CurrentSessionErrorKind, + ForgotPasswordErrorKind, + ForgotPasswordSubmitErrorKind, + SignInWithPasswordErrorKind, + SignUpErrorKind, +} from '../../../../src/authentication/src/authentication/cognito' +/* eslint-enable no-restricted-syntax */ + +import * as listen from './listen' + +// There are unused function parameters in this file. +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// ================= +// === Constants === +// ================= + +/** One second, in milliseconds. */ +const SEC_MS = 1_000 +/** One day, in milliseconds. */ +const DAY_MS = 86_400_000 +/** Ten hours, in seconds. */ +const TEN_HOURS_S = 36_000 + +// =============== +// === Cognito === +// =============== + +const MOCK_ORGANIZATION_ID_KEY = 'mock_organization_id' +const MOCK_EMAIL_KEY = 'mock_email' + +let mockOrganizationId = localStorage.getItem(MOCK_ORGANIZATION_ID_KEY) +let mockEmail = localStorage.getItem(MOCK_EMAIL_KEY) + +/** Thin wrapper around Cognito endpoints from the AWS Amplify library with error handling added. + * This way, the methods don't throw all errors, but define exactly which errors they return. + * The caller can then handle them via pattern matching on the {@link results.Result} type. */ +export class Cognito { + isSignedIn = false + + /** Create a new Cognito wrapper. */ + constructor( + private readonly logger: loggerProvider.Logger, + private readonly supportsDeepLinks: boolean, + private readonly amplifyConfig: config.AmplifyConfig + ) {} + + /** Save the access token to a file for further reuse. */ + saveAccessToken() { + // Ignored. + } + + /** Return the current {@link UserSession}, or `None` if the user is not logged in. + * + * Will refresh the {@link UserSession} if it has expired. */ + async userSession() { + const currentSession = await results.Result.wrapAsync(() => { + const date = Math.floor(Number(new Date()) / SEC_MS) + const expirationDate = date + TEN_HOURS_S + if (!this.isSignedIn) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw original.CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage + } else { + return Promise.resolve({ + isValid: () => true, + getRefreshToken: () => ({ + getToken: () => '', + }), + getIdToken: () => ({ + payload: { + email: mockEmail, + }, + decodePayload: () => ({}), + // Do not need to be the same as the dates for the access token. + getIssuedAt: () => date, + getExpiration: () => expirationDate, + getJwtToken: () => '', + }), + getAccessToken: () => ({ + payload: {}, + decodePayload: () => ({}), + getIssuedAt: () => date, + getExpiration: () => expirationDate, + getJwtToken: () => + `.${window.btoa( + JSON.stringify({ + /* eslint-disable @typescript-eslint/naming-convention */ + sub: '62bdf414-c47f-4c76-a333-c564f841c256', + iss: 'https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_9Kycu2SbD', + client_id: '4j9bfs8e7415erf82l129v0qhe', + origin_jti: '3bd05163-dce7-496e-93f4-ac84c33448aa', + event_id: '7392b8de-66d6-4f60-8050-a90253911e45', + token_use: 'access', + scope: 'aws.cognito.signin.user.admin', + auth_time: date, + exp: expirationDate, + iat: date, + jti: '5ab178b7-97a6-4956-8913-1cffee4a0da1', + username: mockEmail, + /* eslint-enable @typescript-eslint/naming-convention */ + }) + )}.`, + }), + }) + } + }) + const amplifySession = currentSession.mapErr(original.intoCurrentSessionErrorKind) + return amplifySession.map(parseUserSession).unwrapOr(null) + } + + /** Returns the associated organization ID of the current user, which is passed during signup, + * or `null` if the user is not associated with an existing organization. */ + async organizationId() { + return Promise.resolve(mockOrganizationId) + } + + /** Sign up with username and password. + * + * Does not rely on federated identity providers (e.g., Google or GitHub). */ + signUp(username: string, password: string, organizationId: string | null) { + mockOrganizationId = organizationId + if (organizationId != null) { + localStorage.setItem(MOCK_ORGANIZATION_ID_KEY, organizationId) + } else { + localStorage.removeItem(MOCK_ORGANIZATION_ID_KEY) + } + return signUp(this.supportsDeepLinks, username, password, organizationId) + } + + /** Send the email address verification code. + * + * The user will receive a link in their email. The user must click the link to go to the email + * verification page. The email verification page will parse the verification code from the URL. + * If the verification code matches, the email address is marked as verified. Once the email + * address is verified, the user can sign in. */ + confirmSignUp(email: string, code: string) { + mockEmail = email + localStorage.setItem(MOCK_EMAIL_KEY, email) + return confirmSignUp(email, code) + } + + /** Sign in via the Google federated identity provider. + * + * This function will open the Google authentication page in the user's browser. The user will + * be asked to log in to their Google account, and then to grant access to the application. + * After the user has granted access, the browser will be redirected to the application. */ + async signInWithGoogle() { + this.isSignedIn = true + listen.authEventListener?.(listen.AuthEvent.signIn) + await Promise.resolve() + } + + /** Sign in via the GitHub federated identity provider. + * + * This function will open the GitHub authentication page in the user's browser. The user will + * be asked to log in to their GitHub account, and then to grant access to the application. + * After the user has granted access, the browser will be redirected to the application. */ + signInWithGitHub() { + this.isSignedIn = true + listen.authEventListener?.(listen.AuthEvent.signIn) + return Promise.resolve({ + accessKeyId: 'access key id', + sessionToken: 'session token', + secretAccessKey: 'secret access key', + identityId: 'identity id', + authenticated: true, + expiration: new Date(Number(new Date()) + DAY_MS), + }) + } + + /** Sign in with the given username and password. + * + * Does not rely on external identity providers (e.g., Google or GitHub). */ + async signInWithPassword(username: string, _password: string) { + this.isSignedIn = true + mockEmail = username + localStorage.setItem(MOCK_EMAIL_KEY, username) + const result = await results.Result.wrapAsync(async () => { + listen.authEventListener?.(listen.AuthEvent.signIn) + await Promise.resolve() + }) + return result + .mapErr(original.intoAmplifyErrorOrThrow) + .mapErr(original.intoSignInWithPasswordErrorOrThrow) + } + + /** Sign out the current user. */ + async signOut() { + listen.authEventListener?.(listen.AuthEvent.signOut) + this.isSignedIn = false + return Promise.resolve(null) + } + + /** Send a password reset email. + * + * The user will be able to reset their password by following the link in the email, which takes + * them to the "reset password" page of the application. The verification code will be filled in + * automatically. */ + async forgotPassword(_email: string) { + const result = await results.Result.wrapAsync(async () => { + // Ignored. + }) + return result + .mapErr(original.intoAmplifyErrorOrThrow) + .mapErr(original.intoForgotPasswordErrorOrThrow) + } + + /** Submit a new password for the given email address. + * + * The user will have received a verification code in an email, which they will have entered on + * the "reset password" page of the application. This function will submit the new password + * along with the verification code, changing the user's password. */ + async forgotPasswordSubmit(_email: string, _code: string, _password: string) { + const result = await results.Result.wrapAsync(async () => { + // Ignored. + }) + return result.mapErr(original.intoForgotPasswordSubmitErrorOrThrow) + } + + /** Change a password for current authenticated user. + * + * Allow users to independently modify their passwords. The user needs to provide the old + * password, new password, and repeat new password to change their old password to the new + * one. The validation of the repeated new password is handled by the `changePasswordModel` + * component. */ + async changePassword(_oldPassword: string, _newPassword: string) { + const cognitoUserResult = await currentAuthenticatedUser() + if (cognitoUserResult.ok) { + const result = await results.Result.wrapAsync(async () => { + // Ignored. + }) + return result.mapErr(original.intoAmplifyErrorOrThrow) + } else { + return results.Err(cognitoUserResult.val) + } + } +} + +// =================== +// === UserSession === +// =================== + +/** User's session, provides information for identifying and authenticating the user. */ +export interface UserSession { + /** User's email address, used to uniquely identify the user. + * + * Provided by the identity provider the user used to log in. One of: + * + * - GitHub, + * - Google, or + * - Email. */ + email: string + /** User's access token, used to authenticate the user (e.g., when making API calls). */ + accessToken: string +} + +/** Parse a {@link cognito.CognitoUserSession} into a {@link UserSession}. + * @throws If the `email` field of the payload is not a string. */ +function parseUserSession(session: cognito.CognitoUserSession): UserSession { + const payload: Record = session.getIdToken().payload + const email = payload.email + /** The `email` field is mandatory, so we assert that it exists and is a string. */ + if (typeof email !== 'string') { + throw new Error('Payload does not have an email field.') + } else { + const accessToken = `.${window.btoa(JSON.stringify({ username: email }))}.` + return { email, accessToken } + } +} + +// ============== +// === SignUp === +// ============== + +/** A wrapper around the Amplify "sign up" endpoint that converts known errors + * to {@link SignUpError}s. */ +async function signUp( + _supportsDeepLinks: boolean, + _username: string, + _password: string, + _organizationId: string | null +) { + const result = await results.Result.wrapAsync(async () => { + // Ignored. + }) + return result.mapErr(original.intoAmplifyErrorOrThrow).mapErr(original.intoSignUpErrorOrThrow) +} + +// ===================== +// === ConfirmSignUp === +// ===================== + +/** A wrapper around the Amplify "confirm sign up" endpoint that converts known errors + * to {@link ConfirmSignUpError}s. */ +async function confirmSignUp(_email: string, _code: string) { + return results.Result.wrapAsync(async () => { + // Ignored. + }).then(result => + result + .mapErr(original.intoAmplifyErrorOrThrow) + .mapErr(original.intoConfirmSignUpErrorOrThrow) + ) +} + +// ====================== +// === ChangePassword === +// ====================== + +/** A wrapper around the Amplify "current authenticated user" endpoint that converts known errors + * to {@link AmplifyError}s. */ +async function currentAuthenticatedUser() { + const result = await results.Result.wrapAsync( + // The methods are not needed. + // eslint-disable-next-line no-restricted-syntax + async () => await Promise.resolve({} as unknown as amplify.CognitoUser) + ) + return result.mapErr(original.intoAmplifyErrorOrThrow) +} diff --git a/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/listen.tsx b/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/listen.tsx new file mode 100644 index 0000000000..32c9fd6d08 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/mock/authentication/src/authentication/listen.tsx @@ -0,0 +1,16 @@ +/** @file */ +export enum AuthEvent { + customOAuthState = 'customOAuthState', + cognitoHostedUi = 'cognitoHostedUI', + signIn = 'signIn', + signOut = 'signOut', +} + +// This is INTENTIONAL. +// eslint-disable-next-line no-restricted-syntax +export let authEventListener: ((event: AuthEvent, data?: unknown) => void) | null + +/** Listen to authentication state changes. */ +export function registerAuthEventListener(listener: (event: AuthEvent, data?: unknown) => void) { + authEventListener = listener +} diff --git a/app/ide-desktop/lib/dashboard/package.json b/app/ide-desktop/lib/dashboard/package.json index 63194c12aa..a5ca7ee0bf 100644 --- a/app/ide-desktop/lib/dashboard/package.json +++ b/app/ide-desktop/lib/dashboard/package.json @@ -8,7 +8,11 @@ "build": "tsx bundle.ts", "watch": "tsx watch.ts", "start": "tsx start.ts", - "test": "tsx test.ts" + "test": "npx --yes playwright install && npm run test-unit && npm run test-component && npm run test-e2e-and-log", + "test-unit": "playwright test", + "test-component": "playwright test -c playwright-component.config.ts", + "test-e2e": "npx playwright test -c playwright-e2e.config.ts", + "test-e2e-and-log": "npm run test-e2e || npx tsx log-screenshot-diffs.ts" }, "dependencies": { "@heroicons/react": "^2.0.15", @@ -23,19 +27,24 @@ }, "devDependencies": { "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "@modyfi/vite-plugin-yaml": "^1.0.4", + "@playwright/experimental-ct-react": "^1.38.0", + "@playwright/test": "^1.38.0", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", "chalk": "^5.3.0", "enso-authentication": "^1.0.0", "enso-chat": "git://github.com/enso-org/enso-bot", "enso-content": "^1.0.0", + "esbuild-plugin-inline-image": "^0.0.9", "eslint": "^8.49.0", "eslint-plugin-jsdoc": "^46.8.1", "eslint-plugin-react": "^7.32.1", + "playwright": "^1.38.0", "react-toastify": "^9.1.3", "tailwindcss": "^3.2.7", - "typescript": "~5.2.2", - "tsx": "^3.12.6" + "tsx": "^3.12.6", + "typescript": "~5.2.2" }, "optionalDependencies": { "@esbuild/darwin-x64": "^0.17.15", diff --git a/app/ide-desktop/lib/dashboard/playwright-component.config.ts b/app/ide-desktop/lib/dashboard/playwright-component.config.ts new file mode 100644 index 0000000000..d2e5b27063 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/playwright-component.config.ts @@ -0,0 +1,48 @@ +/** @file Playwright component testing configuration. */ +import * as componentTesting from '@playwright/experimental-ct-react' + +import vitePluginYaml from '@modyfi/vite-plugin-yaml' + +// This is an autogenerated file. +/* eslint-disable @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-magic-numbers */ +export default componentTesting.defineConfig({ + testDir: './test-component', + snapshotDir: './__snapshots__', + timeout: 10_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + ...(process.env.CI ? { workers: 1 } : {}), + projects: [ + { + name: 'chromium', + use: { ...componentTesting.devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...componentTesting.devices['Desktop Firefox'] }, + }, + ...(process.env.CI + ? [] + : [ + { + name: 'webkit', + use: { ...componentTesting.devices['Desktop Safari'] }, + }, + ]), + ], + use: { + trace: 'on-first-retry', + ctPort: 3100, + ctViteConfig: { + plugins: [vitePluginYaml()], + define: { + // These are constants, and MUST be `CONSTANT_CASE`. + // eslint-disable-next-line @typescript-eslint/naming-convention + ['REDIRECT_OVERRIDE']: 'undefined', + // eslint-disable-next-line @typescript-eslint/naming-convention + ['process.env.NODE_ENV']: JSON.stringify('production'), + }, + }, + }, +}) diff --git a/app/ide-desktop/lib/dashboard/playwright-e2e.config.ts b/app/ide-desktop/lib/dashboard/playwright-e2e.config.ts new file mode 100644 index 0000000000..63b1bf47d6 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/playwright-e2e.config.ts @@ -0,0 +1,48 @@ +/** @file Playwright browser testing configuration. */ +/** Note that running Playwright in CI poses a number of issues: + * - `backdrop-filter: blur` is disabled, due to issues with Chromium's `--disable-gpu` flag + * (see below). + * - System validation dialogs are not reliable between computers, as they may have different + * default fonts. */ +import * as test from '@playwright/test' + +/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */ + +export default test.defineConfig({ + testDir: './test-e2e', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + ...(process.env.CI ? { workers: 1 } : {}), + expect: { + toHaveScreenshot: { threshold: 0 }, + }, + use: { + baseURL: 'http://localhost:8080', + launchOptions: { + ignoreDefaultArgs: ['--headless'], + args: [ + // Much closer to headful Chromium than classic headless. + '--headless=new', + // Required for `backdrop-filter: blur` to work. + '--use-angle=swiftshader', + // FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by + // the software (CPU) compositor. This SHOULD be fixed eventually, but this flag + // MUST stay as CI does not have a GPU. + '--disable-gpu', + // Fully disable GPU process. + '--disable-software-rasterizer', + // Disable text subpixel antialiasing. + '--font-render-hinting=none', + '--disable-skia-runtime-opts', + '--disable-system-font-check', + '--disable-font-subpixel-positioning', + '--disable-lcd-text', + ], + }, + }, + webServer: { + command: 'npx tsx test-server.ts', + port: 8080, + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/app/ide-desktop/lib/dashboard/playwright.config.ts b/app/ide-desktop/lib/dashboard/playwright.config.ts new file mode 100644 index 0000000000..db3cdc079f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/playwright.config.ts @@ -0,0 +1,8 @@ +/** @file Playwright non-browser testing configuration. While Playwright is not designed for + * non-browser testing, it avoids the fragmentation of installing a different testing framework + * for other tests. */ +import * as test from '@playwright/test' + +export default test.defineConfig({ + testDir: './test', +}) diff --git a/app/ide-desktop/lib/dashboard/playwright/index.html b/app/ide-desktop/lib/dashboard/playwright/index.html new file mode 100644 index 0000000000..2032be5972 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Testing Page + + +
+ + + diff --git a/app/ide-desktop/lib/dashboard/playwright/index.tsx b/app/ide-desktop/lib/dashboard/playwright/index.tsx new file mode 100644 index 0000000000..1927d20692 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/playwright/index.tsx @@ -0,0 +1 @@ +/** @file The file in which the test runner will append the built component code. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts index 3080b538c3..3ca2f9f022 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts @@ -30,13 +30,13 @@ * `kind` field provides a unique string that can be used to brand the error in place of the * `internalCode`, when rethrowing the error. */ import * as amplify from '@aws-amplify/auth' -import * as cognito from 'amazon-cognito-identity-js' +import type * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' import * as detect from 'enso-common/src/detect' import * as config from './config' -import * as loggerProvider from '../providers/logger' +import type * as loggerProvider from '../providers/logger' // ================= // === Constants === @@ -103,7 +103,7 @@ interface UserInfo { * from `unknown` to a typed object. Then, use one of the response error handling functions (e.g. * {@link intoSignUpErrorOrThrow}) to see if the error is one that must be handled by the * application (i.e., it is an error that is relevant to our business logic). */ -interface AmplifyError extends Error { +export interface AmplifyError extends Error { /** Error code for disambiguating the error. */ code: string } @@ -120,7 +120,7 @@ function isAmplifyError(error: unknown): error is AmplifyError { /** Convert the `unknown` error into an {@link AmplifyError} and returns it, or re-throws it if * conversion is not possible. * @throws If the error is not an amplify error. */ -function intoAmplifyErrorOrThrow(error: unknown): AmplifyError { +export function intoAmplifyErrorOrThrow(error: unknown): AmplifyError { if (isAmplifyError(error)) { return error } else { @@ -182,9 +182,7 @@ export class Cognito { /** Save the access token to a file for further reuse. */ saveAccessToken(accessToken: string) { - if (this.amplifyConfig.accessTokenSaver) { - this.amplifyConfig.accessTokenSaver(accessToken) - } + this.amplifyConfig.accessTokenSaver?.(accessToken) } /** Return the current {@link UserSession}, or `None` if the user is not logged in. @@ -387,13 +385,15 @@ function parseUserSession(session: cognito.CognitoUserSession): UserSession { } } -const CURRENT_SESSION_NO_CURRENT_USER_ERROR = { - internalMessage: 'No current user', - kind: 'NoCurrentUser', -} as const - /** Internal IDs of errors that may occur when getting the current session. */ -type CurrentSessionErrorKind = (typeof CURRENT_SESSION_NO_CURRENT_USER_ERROR)['kind'] +export enum CurrentSessionErrorKind { + noCurrentUser = 'NoCurrentUser', +} + +export const CURRENT_SESSION_NO_CURRENT_USER_ERROR = { + internalMessage: 'No current user', + kind: CurrentSessionErrorKind.noCurrentUser, +} /** * Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error, @@ -401,7 +401,7 @@ type CurrentSessionErrorKind = (typeof CURRENT_SESSION_NO_CURRENT_USER_ERROR)['k * * @throws {Error} If the error is not recognized. */ -function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind { +export function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind { if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) { return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind } else { @@ -442,26 +442,27 @@ function intoSignUpParams( } } +/** Internal IDs of errors that may occur when signing up. */ +export enum SignUpErrorKind { + usernameExists = 'UsernameExists', + invalidParameter = 'InvalidParameter', + invalidPassword = 'InvalidPassword', +} + const SIGN_UP_USERNAME_EXISTS_ERROR = { internalCode: 'UsernameExistsException', - kind: 'UsernameExists', -} as const + kind: SignUpErrorKind.usernameExists, +} const SIGN_UP_INVALID_PARAMETER_ERROR = { internalCode: 'InvalidParameterException', - kind: 'InvalidParameter', -} as const + kind: SignUpErrorKind.invalidParameter, +} const SIGN_UP_INVALID_PASSWORD_ERROR = { internalCode: 'InvalidPasswordException', - kind: 'InvalidPassword', -} as const - -/** Internal IDs of errors that may occur when signing up. */ -type SignUpErrorKind = - | (typeof SIGN_UP_INVALID_PARAMETER_ERROR)['kind'] - | (typeof SIGN_UP_INVALID_PASSWORD_ERROR)['kind'] - | (typeof SIGN_UP_USERNAME_EXISTS_ERROR)['kind'] + kind: SignUpErrorKind.invalidPassword, +} /** An error that may occur when signing up. */ export interface SignUpError extends CognitoError { @@ -475,7 +476,7 @@ export interface SignUpError extends CognitoError { * * @throws {Error} If the error is not recognized. */ -function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError { +export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError { if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) { return { kind: SIGN_UP_USERNAME_EXISTS_ERROR.kind, @@ -500,14 +501,16 @@ function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError { // === ConfirmSignUp === // ===================== +/** Internal IDs of errors that may occur when confirming registration. */ +export enum ConfirmSignUpErrorKind { + userAlreadyConfirmed = 'UserAlreadyConfirmed', +} + const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = { internalCode: 'NotAuthorizedException', internalMessage: 'User cannot be confirmed. Current status is CONFIRMED', - kind: 'UserAlreadyConfirmed', -} as const - -/** Internal IDs of errors that may occur when confirming registration. */ -type ConfirmSignUpErrorKind = (typeof CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR)['kind'] + kind: ConfirmSignUpErrorKind.userAlreadyConfirmed, +} /** An error that may occur when confirming registration. */ export interface ConfirmSignUpError extends CognitoError { @@ -518,7 +521,7 @@ export interface ConfirmSignUpError extends CognitoError { /** Convert an {@link AmplifyError} into a {@link ConfirmSignUpError} if it is a known error, * else re-throws the error. * @throws {Error} If the error is not recognized. */ -function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError { +export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError { if ( error.code === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalCode && error.message === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalMessage @@ -540,7 +543,11 @@ function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError // ========================== /** Internal IDs of errors that may occur when signing in with a password. */ -type SignInWithPasswordErrorKind = 'NotAuthorized' | 'UserNotConfirmed' | 'UserNotFound' +export enum SignInWithPasswordErrorKind { + notAuthorized = 'NotAuthorized', + userNotConfirmed = 'UserNotConfirmed', + userNotFound = 'UserNotFound', +} /** An error that may occur when signing in with a password. */ export interface SignInWithPasswordError extends CognitoError { @@ -551,21 +558,21 @@ export interface SignInWithPasswordError extends CognitoError { /** Convert an {@link AmplifyError} into a {@link SignInWithPasswordError} if it is a known error, * else re-throws the error. * @throws {Error} If the error is not recognized. */ -function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPasswordError { +export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPasswordError { switch (error.code) { case 'UserNotFoundException': return { - kind: 'UserNotFound', + kind: SignInWithPasswordErrorKind.userNotFound, message: MESSAGES.signInWithPassword.userNotFound, } case 'UserNotConfirmedException': return { - kind: 'UserNotConfirmed', + kind: SignInWithPasswordErrorKind.userNotConfirmed, message: MESSAGES.signInWithPassword.userNotConfirmed, } case 'NotAuthorizedException': return { - kind: 'NotAuthorized', + kind: SignInWithPasswordErrorKind.notAuthorized, message: MESSAGES.signInWithPassword.incorrectUsernameOrPassword, } default: @@ -577,22 +584,23 @@ function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPass // === ForgotPassword === // ====================== +/** Internal IDs of errors that may occur when requesting a password reset. */ +export enum ForgotPasswordErrorKind { + userNotConfirmed = 'UserNotConfirmed', + userNotFound = 'UserNotFound', +} + const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = { internalCode: 'InvalidParameterException', - kind: 'UserNotConfirmed', - message: `Cannot reset password for the user as there is no registered/verified email \ -or phone_number`, -} as const + kind: ForgotPasswordErrorKind.userNotConfirmed, + message: `Cannot reset password for the user as there is no registered/verified email or \ +phone_number`, +} const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = { internalCode: 'UserNotFoundException', - kind: 'UserNotFound', -} as const - -/** Internal IDs of errors that may occur when requesting a password reset. */ -type ForgotPasswordErrorKind = - | (typeof FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR)['kind'] - | (typeof FORGOT_PASSWORD_USER_NOT_FOUND_ERROR)['kind'] + kind: ForgotPasswordErrorKind.userNotFound, +} /** An error that may occur when requesting a password reset. */ export interface ForgotPasswordError extends CognitoError { @@ -603,7 +611,7 @@ export interface ForgotPasswordError extends CognitoError { /** Convert an {@link AmplifyError} into a {@link ForgotPasswordError} if it is a known error, * else re-throws the error. * @throws {Error} If the error is not recognized. */ -function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError { +export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError { if (error.code === FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.internalCode) { return { kind: FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.kind, @@ -627,7 +635,10 @@ function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordErro // ============================ /** Internal IDs of errors that may occur when resetting a password. */ -type ForgotPasswordSubmitErrorKind = 'AmplifyError' | 'AuthError' +export enum ForgotPasswordSubmitErrorKind { + amplifyError = 'AmplifyError', + authError = 'AuthError', +} /** An error that may occur when resetting a password. */ export interface ForgotPasswordSubmitError extends CognitoError { @@ -638,15 +649,15 @@ export interface ForgotPasswordSubmitError extends CognitoError { /** Convert an {@link AmplifyError} into a {@link ForgotPasswordSubmitError} * if it is a known error, else re-throws the error. * @throws {Error} If the error is not recognized. */ -function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError { +export function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError { if (isAuthError(error)) { return { - kind: 'AuthError', + kind: ForgotPasswordSubmitErrorKind.authError, message: error.log, } } else if (isAmplifyError(error)) { return { - kind: 'AmplifyError', + kind: ForgotPasswordSubmitErrorKind.amplifyError, message: error.message, } } else { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/fontAwesomeIcon.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/fontAwesomeIcon.tsx index d38df4735d..4e8ffe7c0c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/fontAwesomeIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/fontAwesomeIcon.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import * as fontawesome from '@fortawesome/react-fontawesome' -import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' +import type * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' // ======================= // === FontAwesomeIcon === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx index 6de08c13b4..bb12b90f52 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx @@ -27,6 +27,7 @@ export default function SetUsername() { return (
- {children} + {children}
) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/svgMask.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/svgMask.tsx index 38d4019184..2449287612 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/svgMask.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/svgMask.tsx @@ -7,6 +7,7 @@ import * as React from 'react' /** Props for a {@link SvgMask}. */ export interface SvgMaskProps { + alt?: string /** The URL of the SVG to use as the mask. */ src: string title?: string @@ -20,7 +21,7 @@ export interface SvgMaskProps { /** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */ export default function SvgMask(props: SvgMaskProps) { - const { src, title, style, className, onClick } = props + const { alt, src, title, style, className, onClick } = props const urlSrc = `url(${JSON.stringify(src)})` return ( @@ -41,7 +42,7 @@ export default function SvgMask(props: SvgMaskProps) { }} > {/* This is required for this component to have the right size. */} - + {alt}
) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index fb83e7c9d7..5cbb24634e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -8,9 +8,10 @@ import * as router from 'react-router-dom' import * as toast from 'react-toastify' import * as app from '../../components/app' -import * as authServiceModule from '../service' +import type * as authServiceModule from '../service' import * as backendModule from '../../dashboard/backend' import * as backendProvider from '../../providers/backend' +import * as cognitoModule from '../cognito' import * as errorModule from '../../error' import * as http from '../../http' import * as localBackend from '../../dashboard/localBackend' @@ -400,7 +401,7 @@ export function AuthProvider(props: AuthProviderProps) { const result = await cognito.confirmSignUp(email, code) if (result.err) { switch (result.val.kind) { - case 'UserAlreadyConfirmed': + case cognitoModule.ConfirmSignUpErrorKind.userAlreadyConfirmed: break default: throw new errorModule.UnreachableCaseError(result.val.kind) @@ -416,7 +417,7 @@ export function AuthProvider(props: AuthProviderProps) { if (result.ok) { toastSuccess(MESSAGES.signInWithPasswordSuccess) } else { - if (result.val.kind === 'UserNotFound') { + if (result.val.kind === cognitoModule.SignInWithPasswordErrorKind.userNotFound) { navigate(app.REGISTRATION_PATH) } toastError(result.val.message) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx index 18196cef1a..fdfda33419 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx @@ -2,10 +2,11 @@ * currently authenticated user's session. */ import * as React from 'react' -import * as cognito from '../cognito' +import type * as cognito from '../cognito' import * as error from '../../error' import * as hooks from '../../hooks' import * as listen from '../listen' +import * as useRefresh from '../../useRefresh' // ====================== // === SessionContext === @@ -51,7 +52,7 @@ export interface SessionProviderProps { export function SessionProvider(props: SessionProviderProps) { const { mainPageUrl, children, userSession, registerAuthEventListener } = props - const [refresh, doRefresh] = hooks.useRefresh() + const [refresh, doRefresh] = useRefresh.useRefresh() /** Flag used to avoid rendering child components until we've fetched the user's session at least * once. Avoids flash of the login screen when the user is already logged in. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index 81f75f2912..621e63b9f9 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -10,7 +10,7 @@ import * as auth from './config' import * as cognito from './cognito' import * as config from '../config' import * as listen from './listen' -import * as loggerProvider from '../providers/logger' +import type * as loggerProvider from '../providers/logger' // ============= // === Types === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index 3db571e9fb..43a5fa37ba 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -41,7 +41,7 @@ import * as toastify from 'react-toastify' import * as detect from 'enso-common/src/detect' import * as authServiceModule from '../authentication/service' -import * as backend from '../dashboard/backend' +import type * as backend from '../dashboard/backend' import * as hooks from '../hooks' import * as localBackend from '../dashboard/localBackend' import * as shortcutsModule from '../dashboard/shortcuts' @@ -170,7 +170,7 @@ function AppRouter(props: AppProps) { projectManagerUrl, } = props const navigate = hooks.useNavigate() - if (IS_DEV_MODE) { + if (detect.IS_DEV_MODE) { // @ts-expect-error This is used exclusively for debugging. window.navigate = navigate } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts index b5ba85a5d0..3cfd2f4dad 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts @@ -29,7 +29,7 @@ export const ChatUrl = newtype.newtypeConstructor() export const CLOUD_DOMAIN = 'https://cloud.enso.org' /** The current environment that we're running in. */ -export const ENVIRONMENT: Environment = CLOUD_ENV ?? 'production' +export const ENVIRONMENT: Environment = typeof CLOUD_ENV !== 'undefined' ? CLOUD_ENV : 'production' /** All possible URLs used as the OAuth redirects when running the cloud app. */ const CLOUD_REDIRECTS = { @@ -38,7 +38,9 @@ const CLOUD_REDIRECTS = { * when it is created. In the native app, the port is unpredictable, but this is not a problem * because the native app does not use port-based redirects, but deep links. */ development: auth.OAuthRedirect('http://localhost:8080'), - production: auth.OAuthRedirect(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN), + production: auth.OAuthRedirect( + typeof REDIRECT_OVERRIDE !== 'undefined' ? REDIRECT_OVERRIDE : CLOUD_DOMAIN + ), } /** All possible API URLs, sorted by environment. */ @@ -102,7 +104,3 @@ export interface Config { /** Possible values for the environment/user we're running for and whose infrastructure we're * testing against. */ export type Environment = 'npekin' | 'pbuchu' | 'production' - -// =========== -// === API === -// =========== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index 6c3d1e8efa..11acbfd5ab 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -1,5 +1,5 @@ /** @file Type definitions common between all backends. */ -import * as React from 'react' +import type * as React from 'react' import * as dateTime from './dateTime' import * as newtype from '../newtype' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx index bda14ee115..741b84fa20 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/column.tsx @@ -12,17 +12,17 @@ import TagIcon from 'enso-assets/tag.svg' import TimeIcon from 'enso-assets/time.svg' import * as assetEvent from './events/assetEvent' -import * as assetTreeNode from './assetTreeNode' +import type * as assetTreeNode from './assetTreeNode' import * as authProvider from '../authentication/providers/auth' import * as backend from './backend' import * as dateTime from './dateTime' import * as modalProvider from '../providers/modal' import * as permissions from './permissions' import * as sorting from './sorting' -import * as tableColumn from './components/tableColumn' +import type * as tableColumn from './components/tableColumn' import * as uniqueString from '../uniqueString' -import * as assetsTable from './components/assetsTable' +import type * as assetsTable from './components/assetsTable' import * as categorySwitcher from './components/categorySwitcher' import AssetNameColumn from './components/assetNameColumn' import ManagePermissionsModal from './components/managePermissionsModal' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetContextMenu.tsx index 9ffb59e388..67d1479387 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetContextMenu.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import * as toast from 'react-toastify' import * as assetEventModule from '../events/assetEvent' -import * as assetTreeNode from '../assetTreeNode' +import type * as assetTreeNode from '../assetTreeNode' import * as backendModule from '../backend' import * as hooks from '../../hooks' import * as http from '../../http' @@ -16,9 +16,9 @@ import * as backendProvider from '../../providers/backend' import * as loggerProvider from '../../providers/logger' import * as modalProvider from '../../providers/modal' -import * as assetsTable from './assetsTable' +import type * as assetsTable from './assetsTable' import * as categorySwitcher from './categorySwitcher' -import * as tableRow from './tableRow' +import type * as tableRow from './tableRow' import ConfirmDeleteModal from './confirmDeleteModal' import ContextMenu from './contextMenu' import ContextMenuSeparator from './contextMenuSeparator' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetInfoBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetInfoBar.tsx index 3e6d5e77ea..977cde4e11 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetInfoBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetInfoBar.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import DocsIcon from 'enso-assets/docs.svg' import SettingsIcon from 'enso-assets/settings.svg' -import * as backend from '../backend' +import type * as backend from '../backend' import Button from './button' /** Props for an {@link AssetInfoBar}. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetNameColumn.tsx index 3ec16fffda..fc27b78dc8 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetNameColumn.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import * as backendModule from '../backend' -import * as column from '../column' +import type * as column from '../column' import ConnectorNameColumn from './connectorNameColumn' import DirectoryNameColumn from './directoryNameColumn' import FileNameColumn from './fileNameColumn' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx index 65a2cebd4a..229c4f2c1c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetRow.tsx @@ -5,7 +5,7 @@ import BlankIcon from 'enso-assets/blank.svg' import * as assetEventModule from '../events/assetEvent' import * as assetListEventModule from '../events/assetListEvent' -import * as assetTreeNode from '../assetTreeNode' +import type * as assetTreeNode from '../assetTreeNode' import * as authProvider from '../../authentication/providers/auth' import * as backendModule from '../backend' import * as backendProvider from '../../providers/backend' @@ -17,9 +17,10 @@ import * as modalProvider from '../../providers/modal' import * as presenceModule from '../presence' import * as assetsTable from './assetsTable' +import type * as tableRow from './tableRow' import StatelessSpinner, * as statelessSpinner from './statelessSpinner' -import TableRow, * as tableRow from './tableRow' import AssetContextMenu from './assetContextMenu' +import TableRow from './tableRow' // ================ // === AssetRow === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx index 859b46b3ae..138fed336e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/assetsTable.tsx @@ -12,7 +12,7 @@ import * as hooks from '../../hooks' import * as localStorageModule from '../localStorage' import * as localStorageProvider from '../../providers/localStorage' import * as permissions from '../permissions' -import * as presenceModule from '../presence' +import type * as presenceModule from '../presence' import * as shortcuts from '../shortcuts' import * as sorting from '../sorting' import * as string from '../../string' @@ -51,7 +51,7 @@ const ASSET_TYPE_NAME_PLURAL = 'items' const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL) /** The default placeholder row. */ const PLACEHOLDER = ( - + You have no projects yet. Go ahead and create one using the button above, or open a template from the home screen. diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 60471ed74e..7f20c4273a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -31,10 +31,11 @@ export default function ChangePasswordModal() { return (
{ event.stopPropagation() }} - className="flex flex-col bg-frame-selected backdrop-blur-3xl rounded-2xl p-8 w-full max-w-md" >
Change Your Password
@@ -96,7 +97,7 @@ export default function ChangePasswordModal() {
- +
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx index ee7e641650..5eb1a91e9f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -39,6 +39,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { return (
{ element?.focus() }} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx index 67158a4555..34c34c6d88 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/connectorNameColumn.tsx @@ -15,7 +15,7 @@ import * as presence from '../presence' import * as shortcutsModule from '../shortcuts' import * as shortcutsProvider from '../../providers/shortcuts' -import * as column from '../column' +import type * as column from '../column' import EditableSpan from './editableSpan' // ===================== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenus.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenus.tsx index 254eb4e965..c38b489c2f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenus.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenus.tsx @@ -39,6 +39,7 @@ export default function ContextMenus(props: ContextMenusProps) { }} >
-
) : ( @@ -408,6 +409,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
- + )}
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx index 783ac84eed..df9eebfdaf 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectNameColumn.tsx @@ -19,7 +19,7 @@ import * as shortcutsModule from '../shortcuts' import * as shortcutsProvider from '../../providers/shortcuts' import * as validation from '../validation' -import * as column from '../column' +import type * as column from '../column' import EditableSpan from './editableSpan' import ProjectIcon from './projectIcon' import SvgMask from '../../authentication/components/svgMask' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/svgIcon.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/svgIcon.tsx index 3a5d5f448d..a2a7ce470b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/svgIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/svgIcon.tsx @@ -14,7 +14,7 @@ export default function SvgIcon(props: SvgIconProps) { return (
- {children} + {children}
) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/table.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/table.tsx index bf62393de8..fca06a6cdf 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/table.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/table.tsx @@ -7,9 +7,10 @@ import * as set from '../../set' import * as shortcutsModule from '../shortcuts' import * as shortcutsProvider from '../../providers/shortcuts' -import * as tableColumn from './tableColumn' +import type * as tableColumn from './tableColumn' +import type * as tableRow from './tableRow' import Spinner, * as spinner from './spinner' -import TableRow, * as tableRow from './tableRow' +import TableRow from './tableRow' // ================= // === Constants === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/tableRow.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/tableRow.tsx index b5c2b0fe96..7ccfeb417f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/tableRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/tableRow.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import * as modalProvider from '../../providers/modal' -import * as tableColumn from './tableColumn' +import type * as tableColumn from './tableColumn' // ============================= // === Partial `Props` types === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx index 2f7bba45a0..6c3f60fede 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import FindIcon from 'enso-assets/find.svg' -import * as backendModule from '../backend' +import type * as backendModule from '../backend' import * as shortcuts from '../shortcuts' import PageSwitcher, * as pageSwitcher from './pageSwitcher' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userBar.tsx index 389d9b04cd..245481e02c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userBar.tsx @@ -94,6 +94,7 @@ export default function UserBar(props: UserBarProps) { > Open user menu { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx index 34fa6d7c81..78425cb570 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx @@ -40,6 +40,7 @@ export default function UserMenu(props: UserMenuProps) { return (
{ event.stopPropagation() diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/event.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/event.ts index ea1908b080..d0e5d15a06 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/event.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/event.ts @@ -1,6 +1,6 @@ /** @file Utility functions related to event handling. */ -import * as React from 'react' +import type * as React from 'react' // ============================= // === Mouse event utilities === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts index c8e261aa31..11faed89be 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetEvent.ts @@ -1,7 +1,7 @@ /** @file Events related to changes in asset state. */ -import * as backendModule from '../backend' +import type * as backendModule from '../backend' -import * as spinner from '../components/spinner' +import type * as spinner from '../components/spinner' // This is required, to whitelist this event. // eslint-disable-next-line no-restricted-syntax diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts index da314e917d..d98406cde5 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/events/assetListEvent.ts @@ -1,7 +1,7 @@ /** @file Events related to changes in the asset list. */ -import * as backend from '../backend' +import type * as backend from '../backend' -import * as spinner from '../components/spinner' +import type * as spinner from '../components/spinner' // This is required, to whitelist this event. // eslint-disable-next-line no-restricted-syntax diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts index 3811f1c74d..6893790c3d 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localBackend.ts @@ -3,6 +3,8 @@ * Each exported function in the {@link LocalBackend} in this module corresponds to an API endpoint. * The functions are asynchronous and return a {@link Promise} that resolves to the response from * the API. */ +import * as detect from 'enso-common/src/detect' + import * as backend from './backend' import * as dateTime from './dateTime' import * as errorModule from '../error' @@ -33,7 +35,7 @@ export class LocalBackend extends backend.Backend { constructor(projectManagerUrl: string | null) { super() this.projectManager = projectManager.ProjectManager.default(projectManagerUrl) - if (IS_DEV_MODE) { + if (detect.IS_DEV_MODE) { // @ts-expect-error This exists only for debugging purposes. It does not have types // because it MUST NOT be used in this codebase. window.localBackend = this diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts index e6e1545616..b4e96ce3fd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts @@ -1,7 +1,7 @@ /** @file This module defines the Project Manager endpoint. * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import * as dateTime from './dateTime' +import type * as dateTime from './dateTime' import * as newtype from '../newtype' import GLOBAL_CONFIG from '../../../../../../../gui/config.yaml' assert { type: 'yaml' } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts index 6c3ca30b07..a44b7638b0 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackend.ts @@ -3,11 +3,14 @@ * Each exported function in the {@link RemoteBackend} in this module corresponds to * an API endpoint. The functions are asynchronous and return a {@link Promise} that resolves to * the response from the API. */ +import * as detect from 'enso-common/src/detect' + import * as backendModule from './backend' import * as config from '../config' import * as errorModule from '../error' -import * as http from '../http' -import * as loggerProvider from '../providers/logger' +import type * as http from '../http' +import type * as loggerProvider from '../providers/logger' +import * as remoteBackendPaths from './remoteBackendPaths' // ================= // === Constants === @@ -69,117 +72,42 @@ export async function waitUntilProjectIsReady( } } -// ============= -// === Paths === -// ============= - -/** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */ -const LIST_USERS_PATH = 'users' -/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ -const CREATE_USER_PATH = 'users' -/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */ -const INVITE_USER_PATH = 'users/invite' -/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */ -const CREATE_PERMISSION_PATH = 'permissions' -/** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */ -const USERS_ME_PATH = 'users/me' -/** Relative HTTP path to the "list directory" endpoint of the Cloud backend API. */ -const LIST_DIRECTORY_PATH = 'directories' -/** Relative HTTP path to the "create directory" endpoint of the Cloud backend API. */ -const CREATE_DIRECTORY_PATH = 'directories' -/** Relative HTTP path to the "undo delete asset" endpoint of the Cloud backend API. */ -const UNDO_DELETE_ASSET_PATH = 'assets' -/** Relative HTTP path to the "list projects" endpoint of the Cloud backend API. */ -const LIST_PROJECTS_PATH = 'projects' -/** Relative HTTP path to the "create project" endpoint of the Cloud backend API. */ -const CREATE_PROJECT_PATH = 'projects' -/** Relative HTTP path to the "list files" endpoint of the Cloud backend API. */ -const LIST_FILES_PATH = 'files' -/** Relative HTTP path to the "upload file" endpoint of the Cloud backend API. */ -const UPLOAD_FILE_PATH = 'files' -/** Relative HTTP path to the "create secret" endpoint of the Cloud backend API. */ -const CREATE_SECRET_PATH = 'secrets' -/** Relative HTTP path to the "list secrets" endpoint of the Cloud backend API. */ -const LIST_SECRETS_PATH = 'secrets' -/** Relative HTTP path to the "create tag" endpoint of the Cloud backend API. */ -const CREATE_TAG_PATH = 'tags' -/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */ -const LIST_TAGS_PATH = 'tags' -/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */ -const LIST_VERSIONS_PATH = 'versions' -/** Relative HTTP path to the "delete asset" endpoint of the Cloud backend API. */ -function deleteAssetPath(assetId: backendModule.AssetId) { - return `assets/${assetId}` -} -/** Relative HTTP path to the "update directory" endpoint of the Cloud backend API. */ -function updateDirectoryPath(directoryId: backendModule.DirectoryId) { - return `directories/${directoryId}` -} -/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */ -function closeProjectPath(projectId: backendModule.ProjectId) { - return `projects/${projectId}/close` -} -/** Relative HTTP path to the "get project details" endpoint of the Cloud backend API. */ -function getProjectDetailsPath(projectId: backendModule.ProjectId) { - return `projects/${projectId}` -} -/** Relative HTTP path to the "open project" endpoint of the Cloud backend API. */ -function openProjectPath(projectId: backendModule.ProjectId) { - return `projects/${projectId}/open` -} -/** Relative HTTP path to the "project update" endpoint of the Cloud backend API. */ -function projectUpdatePath(projectId: backendModule.ProjectId) { - return `projects/${projectId}` -} -/** Relative HTTP path to the "check resources" endpoint of the Cloud backend API. */ -function checkResourcesPath(projectId: backendModule.ProjectId) { - return `projects/${projectId}/resources` -} -/** Relative HTTP path to the "get project" endpoint of the Cloud backend API. */ -function getSecretPath(secretId: backendModule.SecretId) { - return `secrets/${secretId}` -} -/** Relative HTTP path to the "delete tag" endpoint of the Cloud backend API. */ -function deleteTagPath(tagId: backendModule.TagId) { - return `secrets/${tagId}` -} - // ============= // === Types === // ============= /** HTTP response body for the "list users" endpoint. */ -interface ListUsersResponseBody { +export interface ListUsersResponseBody { users: backendModule.SimpleUser[] } /** HTTP response body for the "list projects" endpoint. */ -interface ListDirectoryResponseBody { +export interface ListDirectoryResponseBody { assets: backendModule.AnyAsset[] } /** HTTP response body for the "list projects" endpoint. */ -interface ListProjectsResponseBody { +export interface ListProjectsResponseBody { projects: backendModule.ListedProjectRaw[] } /** HTTP response body for the "list files" endpoint. */ -interface ListFilesResponseBody { +export interface ListFilesResponseBody { files: backendModule.File[] } /** HTTP response body for the "list secrets" endpoint. */ -interface ListSecretsResponseBody { +export interface ListSecretsResponseBody { secrets: backendModule.SecretInfo[] } /** HTTP response body for the "list tag" endpoint. */ -interface ListTagsResponseBody { +export interface ListTagsResponseBody { tags: backendModule.Tag[] } /** HTTP response body for the "list versions" endpoint. */ -interface ListVersionsResponseBody { +export interface ListVersionsResponseBody { versions: [backendModule.Version, ...backendModule.Version[]] } @@ -211,7 +139,7 @@ export class RemoteBackend extends backendModule.Backend { if (!this.client.defaultHeaders.has('Authorization')) { return this.throw('Authorization header not set.') } else { - if (IS_DEV_MODE) { + if (detect.IS_DEV_MODE) { // @ts-expect-error This exists only for debugging purposes. It does not have types // because it MUST NOT be used in this codebase. window.remoteBackend = this @@ -242,7 +170,7 @@ export class RemoteBackend extends backendModule.Backend { /** Return a list of all users in the same organization. */ override async listUsers(): Promise { - const response = await this.get(LIST_USERS_PATH) + const response = await this.get(remoteBackendPaths.LIST_USERS_PATH) if (!responseIsSuccessful(response)) { return this.throw(`Could not list users in the organization.`) } else { @@ -254,7 +182,10 @@ export class RemoteBackend extends backendModule.Backend { override async createUser( body: backendModule.CreateUserRequestBody ): Promise { - const response = await this.post(CREATE_USER_PATH, body) + const response = await this.post( + remoteBackendPaths.CREATE_USER_PATH, + body + ) if (!responseIsSuccessful(response)) { return this.throw('Could not create user.') } else { @@ -264,7 +195,7 @@ export class RemoteBackend extends backendModule.Backend { /** Invite a new user to the organization by email. */ override async inviteUser(body: backendModule.InviteUserRequestBody): Promise { - const response = await this.post(INVITE_USER_PATH, body) + const response = await this.post(remoteBackendPaths.INVITE_USER_PATH, body) if (!responseIsSuccessful(response)) { return this.throw(`Could not invite user '${body.userEmail}'.`) } else { @@ -277,7 +208,7 @@ export class RemoteBackend extends backendModule.Backend { body: backendModule.CreatePermissionRequestBody ): Promise { const response = await this.post( - CREATE_PERMISSION_PATH, + remoteBackendPaths.CREATE_PERMISSION_PATH, body ) if (!responseIsSuccessful(response)) { @@ -291,7 +222,9 @@ export class RemoteBackend extends backendModule.Backend { * * @returns `null` if a non-successful status code (not 200-299) was received. */ override async usersMe(): Promise { - const response = await this.get(USERS_ME_PATH) + const response = await this.get( + remoteBackendPaths.USERS_ME_PATH + ) if (!responseIsSuccessful(response)) { return null } else { @@ -307,7 +240,7 @@ export class RemoteBackend extends backendModule.Backend { title: string | null ): Promise { const response = await this.get( - LIST_DIRECTORY_PATH + + remoteBackendPaths.LIST_DIRECTORY_PATH + '?' + new URLSearchParams({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -359,7 +292,7 @@ export class RemoteBackend extends backendModule.Backend { body: backendModule.CreateDirectoryRequestBody ): Promise { const response = await this.post( - CREATE_DIRECTORY_PATH, + remoteBackendPaths.CREATE_DIRECTORY_PATH, body ) if (!responseIsSuccessful(response)) { @@ -378,7 +311,7 @@ export class RemoteBackend extends backendModule.Backend { title: string | null ) { const response = await this.put( - updateDirectoryPath(directoryId), + remoteBackendPaths.updateDirectoryPath(directoryId), body ) if (!responseIsSuccessful(response)) { @@ -396,7 +329,7 @@ export class RemoteBackend extends backendModule.Backend { * * @throws An error if a non-successful status code (not 200-299) was received. */ override async deleteAsset(assetId: backendModule.AssetId, title: string | null) { - const response = await this.delete(deleteAssetPath(assetId)) + const response = await this.delete(remoteBackendPaths.deleteAssetPath(assetId)) if (!responseIsSuccessful(response)) { return this.throw( `Unable to delete ${title != null ? `'${title}'` : `asset with ID '${assetId}'`}.` @@ -413,7 +346,7 @@ export class RemoteBackend extends backendModule.Backend { assetId: backendModule.AssetId, title: string | null ): Promise { - const response = await this.patch(UNDO_DELETE_ASSET_PATH, { assetId }) + const response = await this.patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, { assetId }) if (!responseIsSuccessful(response)) { return this.throw( `Unable to restore ${ @@ -429,7 +362,9 @@ export class RemoteBackend extends backendModule.Backend { * * @throws An error if a non-successful status code (not 200-299) was received. */ override async listProjects(): Promise { - const response = await this.get(LIST_PROJECTS_PATH) + const response = await this.get( + remoteBackendPaths.LIST_PROJECTS_PATH + ) if (!responseIsSuccessful(response)) { return this.throw('Could not list projects.') } else { @@ -453,7 +388,10 @@ export class RemoteBackend extends backendModule.Backend { override async createProject( body: backendModule.CreateProjectRequestBody ): Promise { - const response = await this.post(CREATE_PROJECT_PATH, body) + const response = await this.post( + remoteBackendPaths.CREATE_PROJECT_PATH, + body + ) if (!responseIsSuccessful(response)) { return this.throw(`Could not create project with name '${body.projectName}'.`) } else { @@ -468,7 +406,7 @@ export class RemoteBackend extends backendModule.Backend { projectId: backendModule.ProjectId, title: string | null ): Promise { - const response = await this.post(closeProjectPath(projectId), {}) + const response = await this.post(remoteBackendPaths.closeProjectPath(projectId), {}) if (!responseIsSuccessful(response)) { return this.throw( `Could not close project ${ @@ -487,7 +425,9 @@ export class RemoteBackend extends backendModule.Backend { projectId: backendModule.ProjectId, title: string | null ): Promise { - const response = await this.get(getProjectDetailsPath(projectId)) + const response = await this.get( + remoteBackendPaths.getProjectDetailsPath(projectId) + ) if (!responseIsSuccessful(response)) { return this.throw( `Could not get details of project ${ @@ -523,7 +463,7 @@ export class RemoteBackend extends backendModule.Backend { title: string | null ): Promise { const response = await this.post( - openProjectPath(projectId), + remoteBackendPaths.openProjectPath(projectId), body ?? DEFAULT_OPEN_PROJECT_BODY ) if (!responseIsSuccessful(response)) { @@ -544,7 +484,7 @@ export class RemoteBackend extends backendModule.Backend { title: string | null ): Promise { const response = await this.put( - projectUpdatePath(projectId), + remoteBackendPaths.projectUpdatePath(projectId), body ) if (!responseIsSuccessful(response)) { @@ -565,7 +505,9 @@ export class RemoteBackend extends backendModule.Backend { projectId: backendModule.ProjectId, title: string | null ): Promise { - const response = await this.get(checkResourcesPath(projectId)) + const response = await this.get( + remoteBackendPaths.checkResourcesPath(projectId) + ) if (!responseIsSuccessful(response)) { return this.throw( `Could not get resource usage for project ${ @@ -581,7 +523,7 @@ export class RemoteBackend extends backendModule.Backend { * * @throws An error if a non-successful status code (not 200-299) was received. */ override async listFiles(): Promise { - const response = await this.get(LIST_FILES_PATH) + const response = await this.get(remoteBackendPaths.LIST_FILES_PATH) if (!responseIsSuccessful(response)) { return this.throw('Could not list files.') } else { @@ -597,7 +539,7 @@ export class RemoteBackend extends backendModule.Backend { body: Blob ): Promise { const response = await this.postBinary( - UPLOAD_FILE_PATH + + remoteBackendPaths.UPLOAD_FILE_PATH + '?' + new URLSearchParams({ /* eslint-disable @typescript-eslint/naming-convention */ @@ -638,7 +580,10 @@ export class RemoteBackend extends backendModule.Backend { override async createSecret( body: backendModule.CreateSecretRequestBody ): Promise { - const response = await this.post(CREATE_SECRET_PATH, body) + const response = await this.post( + remoteBackendPaths.CREATE_SECRET_PATH, + body + ) if (!responseIsSuccessful(response)) { return this.throw(`Could not create secret with name '${body.secretName}'.`) } else { @@ -653,7 +598,9 @@ export class RemoteBackend extends backendModule.Backend { secretId: backendModule.SecretId, title: string | null ): Promise { - const response = await this.get(getSecretPath(secretId)) + const response = await this.get( + remoteBackendPaths.getSecretPath(secretId) + ) if (!responseIsSuccessful(response)) { return this.throw( `Could not get secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.` @@ -667,7 +614,9 @@ export class RemoteBackend extends backendModule.Backend { * * @throws An error if a non-successful status code (not 200-299) was received. */ override async listSecrets(): Promise { - const response = await this.get(LIST_SECRETS_PATH) + const response = await this.get( + remoteBackendPaths.LIST_SECRETS_PATH + ) if (!responseIsSuccessful(response)) { return this.throw('Could not list secrets.') } else { @@ -681,14 +630,17 @@ export class RemoteBackend extends backendModule.Backend { override async createTag( body: backendModule.CreateTagRequestBody ): Promise { - const response = await this.post(CREATE_TAG_PATH, { - /* eslint-disable @typescript-eslint/naming-convention */ - tag_name: body.name, - tag_value: body.value, - object_type: body.objectType, - object_id: body.objectId, - /* eslint-enable @typescript-eslint/naming-convention */ - }) + const response = await this.post( + remoteBackendPaths.CREATE_TAG_PATH, + { + /* eslint-disable @typescript-eslint/naming-convention */ + tag_name: body.name, + tag_value: body.value, + object_type: body.objectType, + object_id: body.objectId, + /* eslint-enable @typescript-eslint/naming-convention */ + } + ) if (!responseIsSuccessful(response)) { return this.throw(`Could not create create tag with name '${body.name}'.`) } else { @@ -703,7 +655,7 @@ export class RemoteBackend extends backendModule.Backend { params: backendModule.ListTagsRequestParams ): Promise { const response = await this.get( - LIST_TAGS_PATH + + remoteBackendPaths.LIST_TAGS_PATH + '?' + new URLSearchParams({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -721,7 +673,7 @@ export class RemoteBackend extends backendModule.Backend { * * @throws An error if a non-successful status code (not 200-299) was received. */ override async deleteTag(tagId: backendModule.TagId): Promise { - const response = await this.delete(deleteTagPath(tagId)) + const response = await this.delete(remoteBackendPaths.deleteTagPath(tagId)) if (!responseIsSuccessful(response)) { return this.throw(`Could not delete tag with ID '${tagId}'.`) } else { @@ -736,7 +688,7 @@ export class RemoteBackend extends backendModule.Backend { params: backendModule.ListVersionsRequestParams ): Promise { const response = await this.get( - LIST_VERSIONS_PATH + + remoteBackendPaths.LIST_VERSIONS_PATH + '?' + new URLSearchParams({ // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts new file mode 100644 index 0000000000..83e02b947f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/remoteBackendPaths.ts @@ -0,0 +1,77 @@ +/** @file Paths used by the `RemoteBackend`. */ +import type * as backend from './backend' + +// ============= +// === Paths === +// ============= + +/** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */ +export const LIST_USERS_PATH = 'users' +/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ +export const CREATE_USER_PATH = 'users' +/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */ +export const INVITE_USER_PATH = 'users/invite' +/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */ +export const CREATE_PERMISSION_PATH = 'permissions' +/** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */ +export const USERS_ME_PATH = 'users/me' +/** Relative HTTP path to the "list directory" endpoint of the Cloud backend API. */ +export const LIST_DIRECTORY_PATH = 'directories' +/** Relative HTTP path to the "create directory" endpoint of the Cloud backend API. */ +export const CREATE_DIRECTORY_PATH = 'directories' +/** Relative HTTP path to the "undo delete asset" endpoint of the Cloud backend API. */ +export const UNDO_DELETE_ASSET_PATH = 'assets' +/** Relative HTTP path to the "list projects" endpoint of the Cloud backend API. */ +export const LIST_PROJECTS_PATH = 'projects' +/** Relative HTTP path to the "create project" endpoint of the Cloud backend API. */ +export const CREATE_PROJECT_PATH = 'projects' +/** Relative HTTP path to the "list files" endpoint of the Cloud backend API. */ +export const LIST_FILES_PATH = 'files' +/** Relative HTTP path to the "upload file" endpoint of the Cloud backend API. */ +export const UPLOAD_FILE_PATH = 'files' +/** Relative HTTP path to the "create secret" endpoint of the Cloud backend API. */ +export const CREATE_SECRET_PATH = 'secrets' +/** Relative HTTP path to the "list secrets" endpoint of the Cloud backend API. */ +export const LIST_SECRETS_PATH = 'secrets' +/** Relative HTTP path to the "create tag" endpoint of the Cloud backend API. */ +export const CREATE_TAG_PATH = 'tags' +/** Relative HTTP path to the "list tags" endpoint of the Cloud backend API. */ +export const LIST_TAGS_PATH = 'tags' +/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */ +export const LIST_VERSIONS_PATH = 'versions' +/** Relative HTTP path to the "delete asset" endpoint of the Cloud backend API. */ +export function deleteAssetPath(assetId: backend.AssetId) { + return `assets/${assetId}` +} +/** Relative HTTP path to the "update directory" endpoint of the Cloud backend API. */ +export function updateDirectoryPath(directoryId: backend.DirectoryId) { + return `directories/${directoryId}` +} +/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */ +export function closeProjectPath(projectId: backend.ProjectId) { + return `projects/${projectId}/close` +} +/** Relative HTTP path to the "get project details" endpoint of the Cloud backend API. */ +export function getProjectDetailsPath(projectId: backend.ProjectId) { + return `projects/${projectId}` +} +/** Relative HTTP path to the "open project" endpoint of the Cloud backend API. */ +export function openProjectPath(projectId: backend.ProjectId) { + return `projects/${projectId}/open` +} +/** Relative HTTP path to the "project update" endpoint of the Cloud backend API. */ +export function projectUpdatePath(projectId: backend.ProjectId) { + return `projects/${projectId}` +} +/** Relative HTTP path to the "check resources" endpoint of the Cloud backend API. */ +export function checkResourcesPath(projectId: backend.ProjectId) { + return `projects/${projectId}/resources` +} +/** Relative HTTP path to the "get secret" endpoint of the Cloud backend API. */ +export function getSecretPath(secretId: backend.SecretId) { + return `secrets/${secretId}` +} +/** Relative HTTP path to the "delete tag" endpoint of the Cloud backend API. */ +export function deleteTagPath(tagId: backend.TagId) { + return `secrets/${tagId}` +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/shortcuts.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/shortcuts.tsx index a6ea01b437..b853ee1649 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/shortcuts.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/shortcuts.tsx @@ -1,5 +1,5 @@ /** @file A registry for keyboard and mouse shortcuts. */ -import * as React from 'react' +import type * as React from 'react' import AddConnectorIcon from 'enso-assets/add_connector.svg' import AddFolderIcon from 'enso-assets/add_folder.svg' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts index cfa50c3e72..396aa6144a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts @@ -1,5 +1,5 @@ /** @file Contains useful error types common across the module. */ -import * as toastify from 'react-toastify' +import type * as toastify from 'react-toastify' // ===================== // === tryGetMessage === diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/fileIcon.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/fileIcon.ts new file mode 100644 index 0000000000..19d0163bcd --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/fileIcon.ts @@ -0,0 +1,7 @@ +/** @file Return the appropriate file icon given the file name. */ +import TextIcon from 'enso-assets/text.svg' + +/** Return the appropriate icon given the file name. */ +export function fileIcon() { + return TextIcon +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts index 2ef6528ffc..d585573f8b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts @@ -1,5 +1,4 @@ /** @file Utility functions for extracting and manipulating file information. */ -import TextIcon from 'enso-assets/text.svg' // ================================ // === Extract file information === @@ -12,31 +11,5 @@ export function baseName(fileName: string) { /** Extract the file extension from a file name. */ export function fileExtension(fileName: string) { - return fileName.match(/\.(.+?)$/)?.[1] ?? '' -} - -/** Returns the appropriate icon for a specific file extension. */ -export function fileIcon() { - return TextIcon -} - -// =================================== -// === Manipulate file information === -// =================================== - -/** Convert a size in bytes to a human readable size, e.g. in mebibytes. */ -export function toReadableSize(size: number) { - /* eslint-disable @typescript-eslint/no-magic-numbers */ - if (size < 2 ** 10) { - return String(size) + ' B' - } else if (size < 2 ** 20) { - return (size / 2 ** 10).toFixed(2) + ' kiB' - } else if (size < 2 ** 30) { - return (size / 2 ** 30).toFixed(2) + ' MiB' - } else if (size < 2 ** 40) { - return (size / 2 ** 40).toFixed(2) + ' GiB' - } else { - return (size / 2 ** 50).toFixed(2) + ' TiB' - } - /* eslint-enable @typescript-eslint/no-magic-numbers */ + return fileName.match(/\.([^.]+?)$/)?.[1] ?? '' } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx index b38f337058..6b55439f85 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx @@ -3,25 +3,13 @@ import * as React from 'react' import * as router from 'react-router' import * as toastify from 'react-toastify' +import * as detect from 'enso-common/src/detect' + import * as app from './components/app' import * as auth from './authentication/providers/auth' import * as errorModule from './error' import * as loggerProvider from './providers/logger' -// ================== -// === useRefresh === -// ================== - -/** An alias to make the purpose of the returned empty object clearer. */ -export interface RefreshState {} - -/** A hook that contains no state, and is used only to tell React when to re-render. */ -export function useRefresh() { - // Uses an empty object literal because every distinct literal - // is a new reference and therefore is not equal to any other object literal. - return React.useReducer((): RefreshState => ({}), {}) -} - // ====================== // === useToastAndLog === // ====================== @@ -167,7 +155,7 @@ export function useEventHandler( ) { let hasEffectRun = false React.useLayoutEffect(() => { - if (IS_DEV_MODE) { + if (detect.IS_DEV_MODE) { if (hasEffectRun) { // This is the second time this event is being run in React Strict Mode. // Event handlers are not supposed to be idempotent, so it is a mistake to execute it diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx index 0d194c63e2..0c1b0a8931 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx @@ -6,7 +6,8 @@ import * as reactDOM from 'react-dom/client' import * as detect from 'enso-common/src/detect' -import App, * as app from './components/app' +import type * as app from './components/app' +import App from './components/app' // ================= // === Constants === @@ -39,7 +40,7 @@ function run(props: app.AppProps) { // via the browser. const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron() reactDOM.createRoot(root).render( - IS_DEV_MODE ? ( + detect.IS_DEV_MODE ? ( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx index fb9b8adbae..249b34c461 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx @@ -2,7 +2,7 @@ * provider via the shared React context. */ import * as React from 'react' -import * as backendModule from '../dashboard/backend' +import type * as backendModule from '../dashboard/backend' import * as localStorageModule from '../dashboard/localStorage' import * as localStorageProvider from './localStorage' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/useRefresh.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/useRefresh.tsx new file mode 100644 index 0000000000..a39dc7dee0 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/useRefresh.tsx @@ -0,0 +1,18 @@ +/** @file A hook to trigger React re-renders. */ +import * as React from 'react' + +// ================== +// === useRefresh === +// ================== + +// This must not be a `symbol` as it cannot be sent to Playright. +/** The type of the state returned by {@link useRefresh}. */ +// eslint-disable-next-line no-restricted-syntax +export interface RefreshState {} + +/** A hook that contains no state. It is used to trigger React re-renders. */ +export function useRefresh() { + // Uses an empty object literal because every distinct literal + // is a new reference and therefore is not equal to any other object literal. + return React.useReducer((): RefreshState => ({}), {}) +} diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index bfdb620912..373a786d8c 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -1,6 +1,8 @@ /** @file Entry point into the cloud dashboard. */ import * as authentication from 'enso-authentication' +import * as detect from 'enso-common/src/detect' + // ================= // === Constants === // ================= @@ -17,7 +19,7 @@ const SERVICE_WORKER_PATH = './serviceWorker.js' // === Live reload === // =================== -if (IS_DEV_MODE) { +if (detect.IS_DEV_MODE) { new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => { // This acts like `location.reload`, but it preserves the query-string. // The `toString()` is to bypass a lint without using a comment. diff --git a/app/ide-desktop/lib/dashboard/src/test_setup.ts b/app/ide-desktop/lib/dashboard/src/test_setup.ts new file mode 100644 index 0000000000..75a13c0dfb --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/test_setup.ts @@ -0,0 +1,10 @@ +/** @file Global setup for tests. */ + +// ============= +// === setup === +// ============= + +/** Global setup for tests. */ +export default function setup() { + process.env.NODE_ENV = 'production' +} diff --git a/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh.spec.tsx b/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh.spec.tsx new file mode 100644 index 0000000000..4ab2def756 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh.spec.tsx @@ -0,0 +1,26 @@ +/** @file Tests for the `useRefresh` hook. */ +import * as React from 'react' + +import * as test from '@playwright/experimental-ct-react' + +import type * as refresh from './useRefresh/refresh' +import Refresh from './useRefresh/refresh' + +test.test('useRefresh', async ({ mount }) => { + const values = new Set() + const onRefresh = (refreshState: refresh.RefreshState) => { + values.add(refreshState) + } + const component = await mount() + test.expect(values.size).toBe(0) + await component.waitFor({ state: 'attached' }) + test.expect(values.size).toBe(1) + await component.click() + test.expect(values.size, '`onRefresh` is triggered when `doRefresh` is called').toBe(2) + await component.click() + test.expect(values.size, '`refresh` states are all unique').toBe(3) + await component.click() + test.expect(values.size, '`refresh` states are all unique').toBe(4) + await component.click() + test.expect(values.size, '`refresh` states are all unique').toBe(5) +}) diff --git a/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh/refresh.tsx b/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh/refresh.tsx new file mode 100644 index 0000000000..ef3ad8267d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh/refresh.tsx @@ -0,0 +1,27 @@ +/** @file A component for testing the `useRefresh` hook. */ +import * as React from 'react' + +import * as useRefresh from '../../../../src/authentication/src/useRefresh' + +// =============== +// === Refresh === +// =============== + +/** The type of the state returned by {@link hooks.useRefresh}. */ +export type RefreshState = useRefresh.RefreshState + +/** Props for a {@link Refresh}. */ +interface InternalRefreshProps { + onRefresh: (refreshState: RefreshState) => void +} + +/** A component for testing the `useRefresh` hook. */ +export default function Refresh(props: InternalRefreshProps) { + const { onRefresh } = props + const [refresh, doRefresh] = useRefresh.useRefresh() + React.useEffect(() => { + onRefresh(refresh) + }, [refresh, /* should never change */ onRefresh]) + + return
.
+} diff --git a/app/ide-desktop/lib/dashboard/test-e2e/actions.ts b/app/ide-desktop/lib/dashboard/test-e2e/actions.ts new file mode 100644 index 0000000000..fd4b5e4263 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/actions.ts @@ -0,0 +1,276 @@ +/** @file Various actions, locators, and constants used in end-to-end tests. */ +import type * as test from '@playwright/test' + +// ================= +// === Constants === +// ================= + +/** An example password that does not meet validation requirements. */ +export const INVALID_PASSWORD = 'password' +/** An example password that meets validation requirements. */ +export const VALID_PASSWORD = 'Password0!' +/** An example valid email address. */ +export const VALID_EMAIL = 'email@example.com' + +// ================ +// === Locators === +// ================ + +// === Input locators === + +/** Find an email input (if any) on the current page. */ +export function locateEmailInput(page: test.Locator | test.Page) { + return page.getByLabel('E-Mail Address:') +} + +/** Find a password input (if any) on the current page. */ +export function locatePasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Password:', { exact: true }) +} + +/** Find a "confirm password" input (if any) on the current page. */ +export function locateConfirmPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Confirm Password:') +} + +/** Find an "old password" input (if any) on the current page. */ +export function locateOldPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Old Password:') +} + +/** Find a "new password" input (if any) on the current page. */ +export function locateNewPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('New Password:', { exact: true }) +} + +/** Find a "confirm new password" input (if any) on the current page. */ +export function locateConfirmNewPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Confirm New Password:') +} + +/** Find a "username" input (if any) on the current page. */ +export function locateUsernameInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Username') +} + +// === Button locators === + +/** Find a login button (if any) on the current page. */ +export function locateLoginButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Login', exact: true }) +} + +/** Find a register button (if any) on the current page. */ +export function locateRegisterButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Register' }) +} + +/** Find a reset button (if any) on the current page. */ +export function locateResetButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Reset' }) +} + +/** Find a user menu button (if any) on the current page. */ +export function locateUserMenuButton(page: test.Locator | test.Page) { + return page.getByAltText('Open user menu') +} + +/** Find a change password button (if any) on the current page. */ +export function locateChangePasswordButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Change your password' }) +} + +/** Find a "sign out" button (if any) on the current page. */ +export function locateSignOutButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Sign out' }) +} + +/** Find a "set username" button (if any) on the current page. */ +export function locateSetUsernameButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Set Username' }) +} + +/** Find a "delete" button (if any) on the current page. */ +export function locateDeleteButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Delete' }) +} + +/** Find a button to open the editor (if any) on the current page. */ +export function locatePlayOrOpenProjectButton(page: test.Locator | test.Page) { + return page.getByAltText('Open in editor') +} + +/** Find a button to close the project (if any) on the current page. */ +export function locateStopProjectButton(page: test.Locator | test.Page) { + return page.getByAltText('Stop execution') +} + +// === Context menu buttons === + +/** Find an "open" button (if any) on the current page. */ +export function locateOpenButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Open' }) +} + +/** Find an "upload to cloud" button (if any) on the current page. */ +export function locateUploadToCloudButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Upload To Cloud' }) +} + +/** Find a "rename" button (if any) on the current page. */ +export function locateRenameButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Rename' }) +} + +/** Find a "snapshot" button (if any) on the current page. */ +export function locateSnapshotButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Snapshot' }) +} + +/** Find a "move to trash" button (if any) on the current page. */ +export function locateMoveToTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Move To Trash' }) +} + +/** Find a "move all to trash" button (if any) on the current page. */ +export function locateMoveAllToTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Move All To Trash' }) +} + +/** Find a "share" button (if any) on the current page. */ +export function locateShareButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Share' }) +} + +/** Find a "label" button (if any) on the current page. */ +export function locateLabelButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Label' }) +} + +/** Find a "duplicate" button (if any) on the current page. */ +export function locateDuplicateButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Duplicate' }) +} + +/** Find a "copy" button (if any) on the current page. */ +export function locateCopyButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Copy' }) +} + +/** Find a "cut" button (if any) on the current page. */ +export function locateCutButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Cut' }) +} + +/** Find a "download" button (if any) on the current page. */ +export function locateDownloadButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Download' }) +} + +/** Find an "upload files" button (if any) on the current page. */ +export function locateUploadFilesButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Upload Files' }) +} + +/** Find a "new project" button (if any) on the current page. */ +export function locateNewProjectButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Project' }) +} + +/** Find a "new folder" button (if any) on the current page. */ +export function locateNewFolderButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Folder' }) +} + +/** Find a "new data connector" button (if any) on the current page. */ +export function locateNewDataConnectorButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Data Connector' }) +} + +// === Container locators === + +/** Find a drive view (if any) on the current page. */ +export function locateDriveView(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} + +/** Find an assets table (if any) on the current page. */ +export function locateAssetsTable(page: test.Locator | test.Page) { + return locateDriveView(page).getByRole('table') +} + +/** Find assets table rows (if any) on the current page. */ +export function locateAssetsTableRows(page: test.Locator | test.Page) { + return locateAssetsTable(page).getByRole('row') +} + +/** Find a "change password" modal (if any) on the current page. */ +export function locateChangePasswordModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('change-password-modal') +} + +/** Find a "confirm delete" modal (if any) on the current page. */ +export function locateConfirmDeleteModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('confirm-delete-modal') +} + +/** Find a user menu (if any) on the current page. */ +export function locateUserMenu(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('user-menu') +} + +/** Find a "set username" panel (if any) on the current page. */ +export function locateSetUsernamePanel(page: test.Locator | test.Page) { + return page.getByTestId('set-username-panel') +} + +/** Find a set of context menus (if any) on the current page. */ +export function locateContextMenus(page: test.Locator | test.Page) { + return page.getByTestId('context-menus') +} + +// ============= +// === login === +// ============= + +/** Perform a successful login. */ +export async function login( + page: test.Page, + email = 'email@example.com', + password = VALID_PASSWORD +) { + await page.goto('/') + await locateEmailInput(page).fill(email) + await locatePasswordInput(page).fill(password) + await locateLoginButton(page).click() +} + +// ================ +// === mockDate === +// ================ + +/** A placeholder date for visual regression testing. */ +const MOCK_DATE = Number(new Date('01/23/45 01:23:45')) + +/** Replace `Date` with a version that returns a fixed time. */ +export async function mockDate(page: test.Page) { + // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 + await page.addInitScript(`{ + Date = class extends Date { + constructor(...args) { + if (args.length === 0) { + super(${MOCK_DATE}); + } else { + super(...args); + } + } + } + const __DateNowOffset = ${MOCK_DATE} - Date.now(); + const __DateNow = Date.now; + Date.now = () => __DateNow() + __DateNowOffset; + }`) +} diff --git a/app/ide-desktop/lib/dashboard/test-e2e/api.ts b/app/ide-desktop/lib/dashboard/test-e2e/api.ts new file mode 100644 index 0000000000..351daacdd9 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/api.ts @@ -0,0 +1,203 @@ +/** @file The mock API. */ +import type * as test from '@playwright/test' + +import type * as backend from '../src/authentication/src/dashboard/backend' +import * as config from '../src/authentication/src/config' +import * as dateTime from '../src/authentication/src/dashboard/dateTime' +import type * as remoteBackend from '../src/authentication/src/dashboard/remoteBackend' +import * as remoteBackendPaths from '../src/authentication/src/dashboard/remoteBackendPaths' + +// ================= +// === Constants === +// ================= + +/** The HTTP status code representing a bad request. */ +const HTTP_STATUS_BAD_REQUEST = 400 +/* eslint-disable no-restricted-syntax */ +/** An asset ID that is a path glob. */ +const GLOB_ASSET_ID = '*' as backend.AssetId +/** A projet ID that is a path glob. */ +const GLOB_PROJECT_ID = '*' as backend.ProjectId +/** A tag ID that is a path glob. */ +const GLOB_TAG_ID = '*' as backend.TagId +/* eslint-enable no-restricted-syntax */ +/** The base URL for all backend endpoints. */ +const BASE_URL = config.ACTIVE_CONFIG.apiUrl + '/' + +// =============== +// === mockApi === +// =============== + +/** Add route handlers for the mock API to a page. */ +export async function mockApi(page: test.Page) { + await page.route(BASE_URL + '**', (_route, request) => { + throw new Error(`Missing route handler for '${request.url().replace(BASE_URL, '')}'.`) + }) + + // === Endpoints returning arrays === + + await page.route(BASE_URL + remoteBackendPaths.LIST_DIRECTORY_PATH + '*', async route => { + await route.fulfill({ + json: { assets: [] } satisfies remoteBackend.ListDirectoryResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_FILES_PATH + '*', async route => { + await route.fulfill({ + json: { files: [] } satisfies remoteBackend.ListFilesResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_PROJECTS_PATH + '*', async route => { + await route.fulfill({ + json: { projects: [] } satisfies remoteBackend.ListProjectsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_SECRETS_PATH + '*', async route => { + await route.fulfill({ + json: { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_TAGS_PATH + '*', async route => { + await route.fulfill({ + json: { tags: [] } satisfies remoteBackend.ListTagsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_USERS_PATH + '*', async route => { + await route.fulfill({ + json: { users: [] } satisfies remoteBackend.ListUsersResponseBody, + }) + }) + await page.route( + BASE_URL + remoteBackendPaths.LIST_VERSIONS_PATH + '*', + async (route, request) => { + await route.fulfill({ + json: { + versions: [ + { + ami: null, + created: dateTime.toRfc3339(new Date()), + number: { + lifecycle: + // eslint-disable-next-line no-restricted-syntax + 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, + value: '2023.2.1-dev', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-syntax + version_type: (new URL(request.url()).searchParams.get( + 'version_type' + ) ?? '') as backend.VersionType, + } satisfies backend.Version, + ], + }, + }) + } + ) + + // === Unimplemented endpoints === + + await page.route( + BASE_URL + remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), + async route => { + await route.fulfill({ + json: { + organizationId: 'example organization id', + projectId: 'example project id', + name: 'example project name', + state: { + type: 'OpenInProgress', + }, + packageName: 'Project_root', + ideVersion: null, + engineVersion: { + value: '2023.2.1-nightly.2023.9.29', + lifecycle: 'Development', + }, + openedBy: 'email@email.email', + }, + }) + } + ) + + // === Endpoints returning `void` === + + await page.route(BASE_URL + remoteBackendPaths.INVITE_USER_PATH + '*', async route => { + await route.fulfill() + }) + await page.route(BASE_URL + remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async route => { + await route.fulfill() + }) + await page.route(BASE_URL + remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => { + await route.fulfill() + }) + await page.route( + BASE_URL + remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), + async route => { + await route.fulfill() + } + ) + await page.route( + BASE_URL + remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), + async route => { + await route.fulfill() + } + ) + await page.route(BASE_URL + remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => { + await route.fulfill() + }) + + // === Other endpoints === + // eslint-disable-next-line no-restricted-syntax + const defaultEmail = 'email@example.com' as backend.EmailAddress + const defaultUsername = 'user name' + // eslint-disable-next-line no-restricted-syntax + const defaultOrganizationId = 'placeholder organization id' as backend.UserOrOrganizationId + const defaultUser: backend.UserOrOrganization = { + email: defaultEmail, + name: defaultUsername, + id: defaultOrganizationId, + isEnabled: true, + } + let currentUser: backend.UserOrOrganization | null = defaultUser + await page.route( + BASE_URL + remoteBackendPaths.CREATE_USER_PATH + '*', + async (route, request) => { + if (request.method() === 'POST') { + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateUserRequestBody = await request.postDataJSON() + currentUser = { + email: body.userEmail, + name: body.userName, + id: body.organizationId ?? defaultUser.id, + isEnabled: false, + } + await route.fulfill({ json: currentUser }) + } else if (request.method() === 'GET') { + if (currentUser != null) { + await route.fulfill({ json: [] }) + } else { + await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) + } + } + } + ) + await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => { + await route.fulfill({ + json: currentUser, + }) + }) + + return { + defaultEmail, + defaultName: defaultUsername, + defaultOrganizationId, + defaultUser, + /** Returns the current value of `currentUser`. This is a getter, so its return value + * SHOULD NOT be cached. */ + get currentUser() { + return currentUser + }, + setCurrentUser: (user: backend.UserOrOrganization | null) => { + currentUser = user + }, + } +} diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts new file mode 100644 index 0000000000..239d52c391 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts @@ -0,0 +1,46 @@ +/** @file Test the "change password" modal. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as api from './api' + +test.test('change password modal', async ({ page }) => { + await api.mockApi(page) + await actions.login(page) + + // Screenshot #1: Change password modal + await actions.locateUserMenuButton(page).click() + await actions.locateChangePasswordButton(page).click() + await test.expect(actions.locateChangePasswordModal(page)).toHaveScreenshot() + + // Screenshot #2: Invalid old password + await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD) + test.expect( + await page.evaluate(() => document.querySelector('form')?.checkValidity()), + 'form should reject invalid old password' + ).toBe(false) + await actions.locateResetButton(page).click() + + // Screenshot #3: Invalid new password + await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) + test.expect( + await page.evaluate(() => document.querySelector('form')?.checkValidity()), + 'form should reject invalid new password' + ).toBe(false) + await actions.locateResetButton(page).click() + + // Screenshot #4: Invalid "confirm new password" + await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) + test.expect( + await page.evaluate(() => document.querySelector('form')?.checkValidity()), + 'form should reject invalid "confirm new password"' + ).toBe(false) + await actions.locateResetButton(page).click() + + // Screenshot #5: After form submission + await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateResetButton(page).click() + await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png new file mode 100644 index 0000000000..de345d8509 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png new file mode 100644 index 0000000000..8177cfd90c Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-win32.png new file mode 100644 index 0000000000..9f43aa48a1 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts new file mode 100644 index 0000000000..b89458e7e8 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts @@ -0,0 +1,45 @@ +/** @file Test the drive view. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as api from './api' + +test.test('drive view', async ({ page }) => { + await api.mockApi(page) + await actions.mockDate(page) + await actions.login(page) + + // Screenshot #1: Drive view + // Initially, the table contains the header row and the placeholder row. + await test.expect(actions.locateAssetsTableRows(page)).toHaveCount(2) + await test.expect(actions.locateDriveView(page)).toHaveScreenshot() + + // Screenshot #2: Assets table with one asset + await actions.locateNewProjectButton(page).click() + // The placeholder row becomes hidden. + await test.expect(actions.locateAssetsTableRows(page)).toHaveCount(2) + await test.expect(actions.locateAssetsTable(page)).toHaveScreenshot() + + await actions.locateNewProjectButton(page).click() + await test.expect(actions.locateAssetsTableRows(page)).toHaveCount(3) + + // These are guarded by the `not.toBeUndefined` below. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const firstAssetRow = (await actions.locateAssetsTableRows(page).all())[1]! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const secondAssetRow = (await actions.locateAssetsTableRows(page).all())[2]! + test.expect(firstAssetRow).not.toBeUndefined() + test.expect(secondAssetRow).not.toBeUndefined() + // The last opened project needs to be stopped, to remove the toast notification notifying the + // user that project creation may take a while. Previously opened projects are stopped when the + // new project is created. + await actions.locateStopProjectButton(secondAssetRow).click() + + // Screenshot #3: Project context menu + await firstAssetRow.click({ button: 'right' }) + const contextMenu = actions.locateContextMenus(page) + await test.expect(contextMenu).toHaveScreenshot() + + await actions.locateMoveToTrashButton(contextMenu).click() + await test.expect(actions.locateAssetsTableRows(page)).toHaveCount(2) +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png new file mode 100644 index 0000000000..a10a07f24c Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png new file mode 100644 index 0000000000..beacda250b Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-win32.png new file mode 100644 index 0000000000..a06bc6c91a Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-darwin.png new file mode 100644 index 0000000000..536e41c1ae Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-linux.png new file mode 100644 index 0000000000..e5b4fe631e Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-win32.png new file mode 100644 index 0000000000..81ae8e4ea2 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png new file mode 100644 index 0000000000..0a9db72449 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-linux.png new file mode 100644 index 0000000000..4797571a76 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-win32.png new file mode 100644 index 0000000000..d2680f321a Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts new file mode 100644 index 0000000000..a83b98009c --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts @@ -0,0 +1,22 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as api from './api' + +// ============= +// === Tests === +// ============= + +test.test('login and logout', async ({ page }) => { + await api.mockApi(page) + + // Screenshot #1: After sign in + await actions.login(page) + await test.expect(page).toHaveScreenshot() + + // Screenshot #2: After sign out + await actions.locateUserMenuButton(page).click() + await actions.locateSignOutButton(page).click() + await test.expect(page).toHaveScreenshot() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png new file mode 100644 index 0000000000..c8d896b1f9 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png new file mode 100644 index 0000000000..ee1923cd28 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-win32.png new file mode 100644 index 0000000000..b7aaf52499 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-darwin.png new file mode 100644 index 0000000000..ada1b4df20 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-linux.png new file mode 100644 index 0000000000..6a017c69bd Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-win32.png new file mode 100644 index 0000000000..a1348783c0 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts new file mode 100644 index 0000000000..964b891353 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts @@ -0,0 +1,30 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +// ============= +// === Tests === +// ============= + +test.test('login screen', async ({ page }) => { + // Screenshot omitted - it is already taken by `loginLogout.spec.ts`. + await page.goto('/') + + // Screenshot #2: Invalid email + await actions.locateEmailInput(page).fill('invalid email') + test.expect( + await page.evaluate(() => document.querySelector('form')?.checkValidity()), + 'form should reject invalid email' + ).toBe(false) + await actions.locateLoginButton(page).click() + + // Screenshot #3: Invalid password + await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) + await actions.locatePasswordInput(page).type(actions.INVALID_PASSWORD) + test.expect( + await page.evaluate(() => document.querySelector('form')?.checkValidity()), + 'form should reject invalid password' + ).toBe(false) + await actions.locateLoginButton(page).click() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts new file mode 100644 index 0000000000..6cb6ffff80 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts @@ -0,0 +1,45 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as apiModule from './api' + +// ============= +// === Tests === +// ============= + +test.test('sign up flow', async ({ page }) => { + const api = await apiModule.mockApi(page) + api.setCurrentUser(null) + await page.goto('/') + + const email = 'example.email+1234@testing.org' + const name = 'a custom user name' + + // These values should be different, otherwise the email and name may come from the defaults. + test.expect(email).not.toStrictEqual(api.defaultEmail) + test.expect(name).not.toStrictEqual(api.defaultName) + + // Screenshot #1: Set username panel + await actions.locateEmailInput(page).fill(email) + await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateLoginButton(page).click() + await test.expect(actions.locateSetUsernamePanel(page)).toHaveScreenshot() + + // Screenshot #2: Logged in, but account disabled + await actions.locateUsernameInput(page).fill(name) + await actions.locateSetUsernameButton(page).click() + await test.expect(page).toHaveScreenshot() + + // Screenshot #3: Logged in, and account enabled + const currentUser = api.currentUser + test.expect(currentUser).toBeDefined() + if (currentUser != null) { + currentUser.isEnabled = true + } + await actions.login(page, email) + await test.expect(page).toHaveScreenshot() + + test.expect(api.currentUser?.email, 'new user has correct email').toBe(email) + test.expect(api.currentUser?.name, 'new user has correct name').toBe(name) +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-darwin.png new file mode 100644 index 0000000000..9f8149637a Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-linux.png new file mode 100644 index 0000000000..48ae54b89a Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-win32.png new file mode 100644 index 0000000000..3b8da7eb7c Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-darwin.png new file mode 100644 index 0000000000..8724106b27 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-linux.png new file mode 100644 index 0000000000..e710d9ec56 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-win32.png new file mode 100644 index 0000000000..1d08b44d1a Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png new file mode 100644 index 0000000000..c8d896b1f9 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png new file mode 100644 index 0000000000..ee1923cd28 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-win32.png new file mode 100644 index 0000000000..b7aaf52499 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts new file mode 100644 index 0000000000..2ec7c91746 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts @@ -0,0 +1,39 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as apiModule from './api' + +// ============= +// === Tests === +// ============= + +// Note: This does not check that the organization ID is sent in the correct format for the backend. +// It only checks that the organization ID is sent in certain places. +test.test('sign up with organization id', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + const organizationId = 'some testing organization id' + await page.goto( + '/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString() + ) + const api = await apiModule.mockApi(page) + api.setCurrentUser(null) + + // Sign up + await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) + await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateRegisterButton(page).click() + + // Log in + await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) + await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateLoginButton(page).click() + + // Set username + await actions.locateUsernameInput(page).fill('arbitrary username') + await actions.locateSetUsernameButton(page).click() + + test.expect(api.currentUser?.id, 'new user has correct organization id').toBe(organizationId) +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts new file mode 100644 index 0000000000..4bad2b3516 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts @@ -0,0 +1,36 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as apiModule from './api' + +// ============= +// === Tests === +// ============= + +test.test('sign up without organization id', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.goto('/registration') + const api = await apiModule.mockApi(page) + api.setCurrentUser(null) + + // Sign up + await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) + await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateRegisterButton(page).click() + + // Log in + await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) + await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) + await actions.locateLoginButton(page).click() + + // Set username + await actions.locateUsernameInput(page).fill('arbitrary username') + await actions.locateSetUsernameButton(page).click() + + test.expect(api.currentUser?.id, 'new user has correct organization id').toBe( + api.defaultOrganizationId + ) +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts new file mode 100644 index 0000000000..3e5ad7d692 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts @@ -0,0 +1,14 @@ +/** @file Test the user menu. */ +import * as test from '@playwright/test' + +import * as actions from './actions' +import * as api from './api' + +test.test('user menu', async ({ page }) => { + await api.mockApi(page) + await actions.login(page) + + // Screenshot #1: User menu + await actions.locateUserMenuButton(page).click() + await test.expect(actions.locateUserMenu(page)).toHaveScreenshot() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-darwin.png b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-darwin.png new file mode 100644 index 0000000000..2950a5938f Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-darwin.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-linux.png b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-linux.png new file mode 100644 index 0000000000..c9c90b4da2 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-linux.png differ diff --git a/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-win32.png b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-win32.png new file mode 100644 index 0000000000..4e51647a06 Binary files /dev/null and b/app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-win32.png differ diff --git a/app/ide-desktop/lib/dashboard/test-server.ts b/app/ide-desktop/lib/dashboard/test-server.ts new file mode 100644 index 0000000000..a294cca847 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test-server.ts @@ -0,0 +1,73 @@ +/** @file File watch and compile service. */ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as url from 'node:url' + +import * as esbuild from 'esbuild' + +import * as bundler from './esbuild-config' + +// ================= +// === Constants === +// ================= + +/** The path of this file. */ +const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url))) +/** This must be port `8080` because it is defined as such in AWS. */ +const PORT = 8080 +// `outputPath` does not have to be a real directory because `write` is `false`, +// meaning that files will not be written to the filesystem. +// However, the path should still be non-empty in order for `esbuild.serve` to work properly. +const OPTS = bundler.bundlerOptions({ outputPath: '/', devMode: true }) +OPTS.define['REDIRECT_OVERRIDE'] = JSON.stringify(`http://localhost:${PORT}`) +OPTS.entryPoints.push( + path.resolve(THIS_PATH, 'src', 'index.html'), + path.resolve(THIS_PATH, 'src', 'index.tsx'), + path.resolve(THIS_PATH, 'src', 'serviceWorker.ts') +) +OPTS.write = false +OPTS.loader['.html'] = 'copy' +OPTS.plugins.push({ + name: 'inject-mock-modules', + setup: build => { + build.onResolve({ filter: /^\..+$/ }, async args => { + const importerIsMockFile = /[\\/]lib[\\/]dashboard[\\/]mock[\\/]/.test(args.importer) + const sourcePath = path.resolve(path.dirname(args.importer), args.path) + if (!importerIsMockFile && /[\\/]lib[\\/]dashboard[\\/]src[\\/]/.test(sourcePath)) { + const mockPath = sourcePath + .replace('/lib/dashboard/src/', '/lib/dashboard/mock/') + .replace('\\lib\\dashboard\\src\\', '\\lib\\dashboard\\mock\\') + try { + await fs.access(mockPath + '.ts', fs.constants.R_OK) + return { path: mockPath + '.ts' } + } catch { + try { + await fs.access(mockPath + '.tsx', fs.constants.R_OK) + return { path: mockPath + '.tsx' } + } catch { + return + } + } + } else { + // The `if` case above always returns. + // eslint-disable-next-line no-restricted-syntax + return + } + }) + }, +}) + +// =============== +// === Watcher === +// =============== + +/** Start the esbuild watcher. */ +async function serve() { + const builder = await esbuild.context(OPTS) + await builder.serve({ + port: PORT, + servedir: OPTS.outdir, + }) +} + +void serve() diff --git a/app/ide-desktop/lib/dashboard/test.ts b/app/ide-desktop/lib/dashboard/test.ts deleted file mode 100644 index 5ed8bbb3a3..0000000000 --- a/app/ide-desktop/lib/dashboard/test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** @file Basic tests for this */ -import chalk from 'chalk' - -import * as validation from './src/authentication/src/dashboard/validation' - -// ================= -// === Constants === -// ================= - -/** The text displayed on success. */ -const SUCCESS_TEXT = chalk.green('✔') + ' ' -/** The text displayed on failure. */ -const FAILURE_TEXT = chalk.red('✗') + ' ' - -// ================== -// === TestRunner === -// ================== - -/** A simple test runner. */ -class TestRunner { - succeeded = 0 - failed = 0 - total = 0 - - /** Prints a success or error message depending on whether the `condition` is true. */ - expect(condition: boolean, message: string) { - this.total += 1 - if (condition) { - this.succeeded += 1 - } else { - this.failed += 1 - } - const prefix = condition ? SUCCESS_TEXT : FAILURE_TEXT - console.log(`${prefix} ${message}`) - } - - /** Prints a summary of test results. */ - summarize() { - console.log( - `\n${chalk.green(this.succeeded)}/${this.total} tests succeeded, ${chalk.red( - this.failed - )}/${this.total} tests failed` + - (this.failed === 0 ? '\n' + chalk.green('All tests passed') : '') - ) - } -} - -// ============= -// === Tests === -// ============= - -/** Runs all tests. */ -function runAllTests() { - const test = new TestRunner() - const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`) - const emptyPassword = '' - test.expect( - !pattern.test(emptyPassword), - `${chalk.yellow(`'${emptyPassword}'`)} fails validation` - ) - const shortPassword = 'Aa0!' - test.expect(!pattern.test(shortPassword), `${chalk.yellow(`'${shortPassword}'`)} is too short`) - const passwordMissingDigit = 'Aa!Aa!Aa!' - test.expect( - !pattern.test(passwordMissingDigit), - `${chalk.yellow(`'${passwordMissingDigit}'`)} is missing a digit` - ) - const passwordMissingLowercase = 'A0!A0!A0!' - test.expect( - !pattern.test(passwordMissingLowercase), - `${chalk.yellow(`'${passwordMissingLowercase}'`)} is missing a lowercase letter` - ) - const passwordMissingUppercase = 'a0!a0!a0!' - test.expect( - !pattern.test(passwordMissingUppercase), - `${chalk.yellow(`'${passwordMissingUppercase}'`)} is missing an uppercase letter` - ) - const passwordMissingSymbol = 'Aa0Aa0Aa0' - test.expect( - !pattern.test(passwordMissingSymbol), - `${chalk.yellow(`'${passwordMissingSymbol}'`)} is missing a symbol` - ) - const validPassword = 'Aa0!Aa0!' - test.expect( - pattern.test(validPassword), - `${chalk.yellow(`'${validPassword}'`)} passes validation` - ) - const basicPassword = 'Password0!' - test.expect( - pattern.test(basicPassword), - `${chalk.yellow(`'${basicPassword}'`)} passes validation` - ) - const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3' - test.expect( - pattern.test(issue7498Password), - `${chalk.yellow(`'${issue7498Password}'`)} passes validation` - ) - test.summarize() -} - -runAllTests() diff --git a/app/ide-desktop/lib/dashboard/test/authentication/src/error.spec.ts b/app/ide-desktop/lib/dashboard/test/authentication/src/error.spec.ts new file mode 100644 index 0000000000..f3f0d538c9 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test/authentication/src/error.spec.ts @@ -0,0 +1,16 @@ +/** @file Tests for `error.ts`. */ +import * as test from '@playwright/test' + +import * as error from '../../../src/authentication/src/error' + +// ============= +// === Tests === +// ============= + +test.test('tryGetMessage', () => { + const message = 'A custom error message.' + test.expect(error.tryGetMessage(new Error(message))).toBe(message) + test.expect(error.tryGetMessage(message)).toBeNull() + test.expect(error.tryGetMessage({})).toBeNull() + test.expect(error.tryGetMessage(null)).toBeNull() +}) diff --git a/app/ide-desktop/lib/dashboard/test/authentication/src/fileInfo.spec.ts b/app/ide-desktop/lib/dashboard/test/authentication/src/fileInfo.spec.ts new file mode 100644 index 0000000000..981672ff01 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test/authentication/src/fileInfo.spec.ts @@ -0,0 +1,14 @@ +/** @file Tests for `fileInfo.ts`. */ +import * as test from '@playwright/test' + +import * as fileInfo from '../../../src/authentication/src/fileInfo' + +// ============= +// === Tests === +// ============= + +test.test('fileExtension', () => { + test.expect(fileInfo.fileExtension('image.png')).toBe('png') + test.expect(fileInfo.fileExtension('.gif')).toBe('gif') + test.expect(fileInfo.fileExtension('fileInfo.spec.js')).toBe('js') +}) diff --git a/app/ide-desktop/lib/dashboard/test/authentication/src/password.spec.ts b/app/ide-desktop/lib/dashboard/test/authentication/src/password.spec.ts new file mode 100644 index 0000000000..b2c84a8be4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/test/authentication/src/password.spec.ts @@ -0,0 +1,42 @@ +/** @file Basic tests for this */ +import * as test from '@playwright/test' + +import * as validation from '../../../src/authentication/src/dashboard/validation' + +// ============= +// === Tests === +// ============= + +/** Runs all tests. */ +test.test('password validation', () => { + const pattern = new RegExp(`^(?:${validation.PASSWORD_PATTERN})$`) + const emptyPassword = '' + test.expect(emptyPassword, `'${emptyPassword}' fails validation`).not.toMatch(pattern) + const shortPassword = 'Aa0!' + test.expect(shortPassword, `'${shortPassword}' is too short`).not.toMatch(pattern) + const passwordMissingDigit = 'Aa!Aa!Aa!' + test.expect(passwordMissingDigit, `'${passwordMissingDigit}' is missing a digit`).not.toMatch( + pattern + ) + const passwordMissingLowercase = 'A0!A0!A0!' + test.expect( + passwordMissingLowercase, + `'${passwordMissingLowercase}' is missing a lowercase letter` + ).not.toMatch(pattern) + const passwordMissingUppercase = 'a0!a0!a0!' + test.expect( + passwordMissingUppercase, + `'${passwordMissingUppercase}' is missing an uppercase letter` + ).not.toMatch(pattern) + const passwordMissingSymbol = 'Aa0Aa0Aa0' + test.expect( + passwordMissingSymbol, + `'${passwordMissingSymbol}' is missing a symbol` + ).not.toMatch(pattern) + const validPassword = 'Aa0!Aa0!' + test.expect(validPassword, `'${validPassword}' passes validation`).toMatch(pattern) + const basicPassword = 'Password0!' + test.expect(basicPassword, `'${basicPassword}' passes validation`).toMatch(pattern) + const issue7498Password = 'ÑéFÛÅÐåÒ.ú¿¼\u00b4N@aö¶U¹jÙÇ3' + test.expect(issue7498Password, `'${issue7498Password}' passes validation`).toMatch(pattern) +}) diff --git a/app/ide-desktop/lib/dashboard/watch.ts b/app/ide-desktop/lib/dashboard/watch.ts index 58a1cd9c57..073e89014d 100644 --- a/app/ide-desktop/lib/dashboard/watch.ts +++ b/app/ide-desktop/lib/dashboard/watch.ts @@ -20,9 +20,8 @@ const HTTP_STATUS_OK = 200 // `outputPath` does not have to be a real directory because `write` is `false`, // meaning that files will not be written to the filesystem. // However, the path should still be non-empty in order for `esbuild.serve` to work properly. -const ARGS: bundler.Arguments = { outputPath: '/', devMode: process.env.DEV_MODE !== 'false' } -const OPTS = bundler.bundlerOptions(ARGS) -OPTS.define.REDIRECT_OVERRIDE = JSON.stringify(`http://localhost:${PORT}`) +const OPTS = bundler.bundlerOptions({ outputPath: '/', devMode: process.env.DEV_MODE !== 'false' }) +OPTS.define['REDIRECT_OVERRIDE'] = JSON.stringify(`http://localhost:${PORT}`) OPTS.entryPoints.push( path.resolve(THIS_PATH, 'src', 'index.html'), path.resolve(THIS_PATH, 'src', 'index.tsx'), diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 9f8d261f76..0bfbe4a0e6 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -2,7 +2,7 @@ * These are from variables defined at build time, environment variables, * monkeypatching on `window` and generated code. */ // This file is being imported for its types. -// eslint-disable-next-line no-restricted-syntax +// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/consistent-type-imports import * as buildJson from './../../build.json' assert { type: 'json' } // ============= diff --git a/app/ide-desktop/lib/types/modules.d.ts b/app/ide-desktop/lib/types/modules.d.ts index 2966c84042..13d789f4c3 100644 --- a/app/ide-desktop/lib/types/modules.d.ts +++ b/app/ide-desktop/lib/types/modules.d.ts @@ -150,20 +150,20 @@ declare module 'eslint-plugin-react-hooks' { } declare module 'esbuild-plugin-time' { - import * as esbuild from 'esbuild' + import type * as esbuild from 'esbuild' export default function (name?: string): esbuild.Plugin } declare module 'tailwindcss/nesting/index.js' { - import * as nested from 'postcss-nested' + import type * as nested from 'postcss-nested' const DEFAULT: nested.Nested export default DEFAULT } declare module 'create-servers' { - import * as http from 'node:http' + import type * as http from 'node:http' /** Configuration options for `create-servers`. */ interface CreateServersOptions { diff --git a/package-lock.json b/package-lock.json index 307c5e3d3f..8afb02cce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,10 @@ "tslib": "^2.6.2" }, "devDependencies": { + "@playwright/experimental-ct-react": "^1.38.0", + "@playwright/test": "^1.38.0", "npm-run-all": "^4.1.5", + "playwright": "^1.38.0", "prettier": "^3.0.0" } }, @@ -45,7 +48,6 @@ "pinia": "^2.1.6", "postcss-inline-svg": "^6.0.0", "postcss-nesting": "^12.0.1", - "rollup-plugin-visualizer": "^5.9.2", "sha3": "^2.1.4", "sucrase": "^3.34.0", "vue": "^3.3.4", @@ -246,15 +248,20 @@ }, "devDependencies": { "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "@modyfi/vite-plugin-yaml": "^1.0.4", + "@playwright/experimental-ct-react": "^1.38.0", + "@playwright/test": "^1.38.0", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", "chalk": "^5.3.0", "enso-authentication": "^1.0.0", "enso-chat": "git://github.com/enso-org/enso-bot", "enso-content": "^1.0.0", + "esbuild-plugin-inline-image": "^0.0.9", "eslint": "^8.49.0", "eslint-plugin-jsdoc": "^46.8.1", "eslint-plugin-react": "^7.32.1", + "playwright": "^1.38.0", "react-toastify": "^9.1.3", "tailwindcss": "^3.2.7", "tsx": "^3.12.6", @@ -2813,6 +2820,42 @@ "node": ">= 10.0.0" } }, + "node_modules/@modyfi/vite-plugin-yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.0.4.tgz", + "integrity": "sha512-qkT0KiR3AQQRfUvDzLv4+1rYAzXj+QmGhAbyUd0Ordf9xynK76i758lk5GiEfxuQxbvdqDaJ9oXkH/KacbSjQQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "5.0.2", + "js-yaml": "4.1.0", + "tosource": "2.0.0-alpha.3" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/@modyfi/vite-plugin-yaml/node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -2895,10 +2938,44 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/experimental-ct-react": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-react/-/experimental-ct-react-1.38.0.tgz", + "integrity": "sha512-RRQi99dNWDlkdSIUJpva5T0f+Nq6JSb0fR6MatvJDJkgiARaytk57SgquTmFHglyNu4p/Qj4lRxCCIEy0uZDGA==", + "dev": true, + "dependencies": { + "@playwright/experimental-ct-core": "1.38.0", + "@vitejs/plugin-react": "^4.0.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@playwright/experimental-ct-react/node_modules/@playwright/experimental-ct-core": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.38.0.tgz", + "integrity": "sha512-jspVRe+D9/gM8aZ6g5TVybSKnYi9OfvUtq67WHo22OfxENXBdDe0hHHlLRQNSdmuKcE5goG8WNVZvOWjolTb/Q==", + "dev": true, + "dependencies": { + "playwright": "1.38.0", + "playwright-core": "1.38.0", + "vite": "^4.3.9" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@playwright/test": { "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.0.tgz", + "integrity": "sha512-xis/RXXsLxwThKnlIXouxmIvvT3zvQj1JE39GsNieMUrMpb3/GySHDh2j8itCG22qKVD4MYLBp7xB73cUW/UUw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "playwright": "1.38.0" }, @@ -7671,7 +7748,8 @@ }, "node_modules/esbuild-plugin-inline-image": { "version": "0.0.9", - "license": "MIT" + "resolved": "https://registry.npmjs.org/esbuild-plugin-inline-image/-/esbuild-plugin-inline-image-0.0.9.tgz", + "integrity": "sha512-pw3ZgN2phh32Z7BpKrhRDtmI+iVCl+Gc0BLOT9croXg1MnMjRuN7aXhIQirhLeK39erkIwfFlhy6xieroBGc1Q==" }, "node_modules/esbuild-plugin-time": { "version": "1.0.0", @@ -8638,6 +8716,20 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "dev": true, @@ -9906,6 +9998,7 @@ }, "node_modules/is-wsl": { "version": "2.2.0", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -9916,6 +10009,7 @@ }, "node_modules/is-wsl/node_modules/is-docker": { "version": "2.2.1", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -12196,8 +12290,9 @@ }, "node_modules/playwright": { "version": "1.38.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.0.tgz", + "integrity": "sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==", "dev": true, - "license": "Apache-2.0", "dependencies": { "playwright-core": "1.38.0" }, @@ -13516,77 +13611,6 @@ "rollup-plugin-inject": "^3.0.0" } }, - "node_modules/rollup-plugin-visualizer": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.9.2.tgz", - "integrity": "sha512-waHktD5mlWrYFrhOLbti4YgQCn1uR24nYsNuXxg7LkPH8KdTXVWR9DNY1WU0QqokyMixVXJS4J04HNrVTMP01A==", - "dependencies": { - "open": "^8.4.0", - "picomatch": "^2.3.1", - "source-map": "^0.7.4", - "yargs": "^17.5.1" - }, - "bin": { - "rollup-plugin-visualizer": "dist/bin/cli.js" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "rollup": "2.x || 3.x" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/rollup-plugin-visualizer/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup-plugin-visualizer/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rollup-plugin-visualizer/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rollup-plugin-visualizer/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, "node_modules/rollup-pluginutils": { "version": "2.8.2", "dev": true, @@ -14936,6 +14960,15 @@ "node": ">=8.0" } }, + "node_modules/tosource": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/tosource/-/tosource-2.0.0-alpha.3.tgz", + "integrity": "sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/totalist": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index c24e749e11..6bc959dd74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "devDependencies": { + "@playwright/experimental-ct-react": "^1.38.0", + "@playwright/test": "^1.38.0", "npm-run-all": "^4.1.5", + "playwright": "^1.38.0", "prettier": "^3.0.0" }, "dependencies": {