Dashboard tests (#7656)

- Implements https://github.com/enso-org/cloud-v2/issues/631
- Tests for dashboard (`app/ide-desktop/lib/dashboard/`):
- End-to-end tests
- Unit tests
- Component tests

The purpose of this PR is to introduce the testing framework - more tests can be added later in separate PRs.

# Important Notes
To test, run `npm run test` in `app/ide-desktop`, or `app/ide-desktop/lib/dashboard/`. All tests should pass.

Individual test types can be run using `npm run test-unit`, `npm run test-component` and `npm run test-e2e` in `app/ide-desktop/lib/dashboard/`.
Individual end-to-end tests can be run using `npx playwright test -c playwright-e2e.config.ts test-e2e/<file name>.spec.ts` in `app/ide-desktop/lib/dashboard/`.

End-to-end tests require internet access to pass (for things like fonts).

This PR *does* check in screenshots to guard against visual regessions (and/or to make visual changes obvious)
This commit is contained in:
somebody1234 2023-10-11 20:24:33 +10:00 committed by GitHub
parent 94d3a05905
commit 3c31155fe9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
130 changed files with 2089 additions and 520 deletions

1
.gitattributes vendored
View File

@ -1 +1,2 @@
* text eol=lf
*.png binary

View File

@ -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

View File

@ -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: {

View File

@ -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'

View File

@ -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'

View File

@ -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`.

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -114,7 +114,8 @@ const ALL_BUNDLES_READY = new Promise<Watches>((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)

View File

@ -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 ===
// ================

View File

@ -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',

View File

@ -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.

View File

@ -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(

View File

@ -0,0 +1,4 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -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',

View File

@ -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)

View File

@ -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<cognito.CognitoUserSession>({
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<string, unknown> = 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<amplify.CognitoUser>({} as unknown as amplify.CognitoUser)
)
return result.mapErr(original.intoAmplifyErrorOrThrow)
}

View File

@ -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
}

View File

@ -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",

View File

@ -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'),
},
},
},
})

View File

@ -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,
},
})

View File

@ -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',
})

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Page</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1 @@
/** @file The file in which the test runner will append the built component code. */

View File

@ -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 {

View File

@ -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 ===

View File

@ -27,6 +27,7 @@ export default function SetUsername() {
return (
<div className="min-h-screen flex flex-col items-center justify-center">
<div
data-testid="set-username-panel"
className={
'flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full ' +
'max-w-md'

View File

@ -14,7 +14,7 @@ export default function SvgIcon(props: SvgIconProps) {
return (
<div className="inline-flex items-center justify-center absolute left-0 top-0 h-full w-10 text-gray-400">
<span>{children}</span>
{children}
</div>
)
}

View File

@ -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. */}
<img src={src} className="opacity-0" />
<img alt={alt} src={src} className="opacity-0" />
</div>
)
}

View File

@ -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)

View File

@ -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. */

View File

@ -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 ===

View File

@ -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
}

View File

@ -29,7 +29,7 @@ export const ChatUrl = newtype.newtypeConstructor<ChatUrl>()
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 ===
// ===========

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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}. */

View File

@ -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'

View File

@ -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 ===

View File

@ -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 = (
<span className="opacity-75 px-1.5">
<span className="opacity-75">
You have no projects yet. Go ahead and create one using the button above, or open a template
from the home screen.
</span>

View File

@ -31,10 +31,11 @@ export default function ChangePasswordModal() {
return (
<Modal centered className="bg-dim">
<div
data-testid="change-password-modal"
className="flex flex-col bg-frame-selected backdrop-blur-3xl rounded-2xl p-8 w-full max-w-md"
onClick={event => {
event.stopPropagation()
}}
className="flex flex-col bg-frame-selected backdrop-blur-3xl rounded-2xl p-8 w-full max-w-md"
>
<div className="self-center text-xl">Change Your Password</div>
<div className="mt-10">
@ -96,7 +97,7 @@ export default function ChangePasswordModal() {
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="new_password_confirm">Confirm New Password:</label>
<label htmlFor="confirm_new_password">Confirm New Password:</label>
<div className="relative">
<SvgIcon>
<SvgMask src={LockIcon} />

View File

@ -39,6 +39,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
return (
<Modal centered className="bg-dim">
<div
data-testid="confirm-delete-modal"
ref={element => {
element?.focus()
}}

View File

@ -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'
// =====================

View File

@ -39,6 +39,7 @@ export default function ContextMenus(props: ContextMenusProps) {
}}
>
<div
data-testid="context-menus"
ref={contextMenuRef}
style={{ left, top }}
className="sticky flex pointer-events-none items-start gap-0.5 w-min"

View File

@ -21,7 +21,7 @@ import * as modalProvider from '../../providers/modal'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as pageSwitcher from './pageSwitcher'
import * as spinner from './spinner'
import type * as spinner from './spinner'
import Chat, * as chat from './chat'
import Drive from './drive'
import Editor from './editor'
@ -422,7 +422,6 @@ export default function Dashboard(props: DashboardProps) {
isListingLocalDirectoryAndWillFail={isListingLocalDirectoryAndWillFail}
isListingRemoteDirectoryAndWillFail={isListingRemoteDirectoryAndWillFail}
/>
<TheModal />
<Editor
hidden={page !== pageSwitcher.Page.editor}
supportsLocalBackend={supportsLocalBackend}

View File

@ -9,7 +9,7 @@ import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as column from '../column'
import type * as column from '../column'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import * as common from 'enso-common'
import * as assetEventModule from '../events/assetEvent'
import type * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
@ -14,6 +14,7 @@ import * as localStorageProvider from '../../providers/localStorage'
import * as app from '../../components/app'
import * as pageSwitcher from './pageSwitcher'
import type * as spinner from './spinner'
import CategorySwitcher, * as categorySwitcher from './categorySwitcher'
import AssetsTable from './assetsTable'
import DriveBar from './driveBar'
@ -62,7 +63,6 @@ export default function Drive(props: DriveProps) {
dispatchAssetListEvent,
assetEvents,
dispatchAssetEvent,
doCreateProject,
doOpenEditor,
doCloseEditor,
loadingProjectManagerDidFail,
@ -109,6 +109,22 @@ export default function Drive(props: DriveProps) {
[backend, organization, toastAndLog, /* should never change */ dispatchAssetListEvent]
)
const doCreateProject = React.useCallback(
(
templateId: string | null,
onSpinnerStateChange?: (state: spinner.SpinnerState) => void
) => {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.newProject,
parentKey: null,
parentId: null,
templateId: templateId ?? null,
onSpinnerStateChange: onSpinnerStateChange ?? null,
})
},
[/* should never change */ dispatchAssetListEvent]
)
const doCreateDirectory = React.useCallback(() => {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.newFolder,
@ -174,6 +190,7 @@ export default function Drive(props: DriveProps) {
</div>
) : (
<div
data-testid="drive-view"
className={`flex flex-col flex-1 overflow-hidden gap-2.5 px-3.25 mt-8 ${
hidden ? 'hidden' : ''
}`}

View File

@ -7,14 +7,14 @@ import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
import * as fileInfo from '../../fileInfo'
import * as fileIcon from '../../fileIcon'
import * as hooks from '../../hooks'
import * as indent from '../indent'
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'
import SvgMask from '../../authentication/components/svgMask'
@ -125,7 +125,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
}
}}
>
<SvgMask src={fileInfo.fileIcon()} className="m-1" />
<SvgMask src={fileIcon.fileIcon()} className="m-1" />
<EditableSpan
editable={false}
onSubmit={async newTitle => {

View File

@ -1,7 +1,7 @@
/** @file Home screen. */
import * as React from 'react'
import * as spinner from './spinner'
import type * as spinner from './spinner'
import Samples from './samples'
import WhatsNew from './whatsNew'

View File

@ -1,8 +1,8 @@
/** @file A selector for all possible permissions. */
import * as React from 'react'
import * as backend from '../backend'
import * as permissions from '../permissions'
import type * as backend from '../backend'
import type * as permissions from '../permissions'
import * as permissionsModule from '../permissions'
import Modal from './modal'

View File

@ -359,7 +359,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
doOpenManually(item.id)
}}
>
<SvgMask className={ICON_CLASSES} src={PlayIcon} />
<SvgMask alt="Open in editor" className={ICON_CLASSES} src={PlayIcon} />
</button>
)
case backendModule.ProjectState.openInProgress:
@ -382,6 +382,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<Spinner size={ICON_SIZE_PX} state={spinnerState} />
</div>
<SvgMask
alt="Stop execution"
src={StopIcon}
className={`${ICON_CLASSES} ${isRunningInBackground ? 'text-green' : ''}`}
/>
@ -408,6 +409,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<Spinner size={24} state={spinnerState} />
</div>
<SvgMask
alt="Stop execution"
src={StopIcon}
className={`${ICON_CLASSES} ${
isRunningInBackground ? 'text-green' : ''
@ -423,7 +425,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
openIde(true)
}}
>
<SvgMask src={ArrowUpIcon} className={ICON_CLASSES} />
<SvgMask
alt="Open in editor"
src={ArrowUpIcon}
className={ICON_CLASSES}
/>
</button>
)}
</div>

View File

@ -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'

View File

@ -14,7 +14,7 @@ export default function SvgIcon(props: SvgIconProps) {
return (
<div className="inline-flex items-center justify-center absolute left-0 top-0 h-full w-10 text-gray-400">
<span>{children}</span>
{children}
</div>
)
}

View File

@ -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 ===

View File

@ -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 ===

View File

@ -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'

View File

@ -94,6 +94,7 @@ export default function UserBar(props: UserBarProps) {
>
<img
src={DefaultUserIcon}
alt="Open user menu"
height={28}
width={28}
onDragStart={event => {

View File

@ -40,6 +40,7 @@ export default function UserMenu(props: UserMenuProps) {
return (
<Modal className="absolute overflow-hidden bg-dim w-full h-full">
<div
data-testid="user-menu"
className="absolute flex flex-col bg-frame-selected backdrop-blur-3xl rounded-2xl gap-3 right-2.25 top-2.25 w-51.5 px-2 py-2.25"
onClick={event => {
event.stopPropagation()

View File

@ -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 ===

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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' }

View File

@ -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<backendModule.SimpleUser[]> {
const response = await this.get<ListUsersResponseBody>(LIST_USERS_PATH)
const response = await this.get<ListUsersResponseBody>(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<backendModule.UserOrOrganization> {
const response = await this.post<backendModule.UserOrOrganization>(CREATE_USER_PATH, body)
const response = await this.post<backendModule.UserOrOrganization>(
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<void> {
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<void> {
const response = await this.post<backendModule.UserOrOrganization>(
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<backendModule.UserOrOrganization | null> {
const response = await this.get<backendModule.UserOrOrganization>(USERS_ME_PATH)
const response = await this.get<backendModule.UserOrOrganization>(
remoteBackendPaths.USERS_ME_PATH
)
if (!responseIsSuccessful(response)) {
return null
} else {
@ -307,7 +240,7 @@ export class RemoteBackend extends backendModule.Backend {
title: string | null
): Promise<backendModule.AnyAsset[]> {
const response = await this.get<ListDirectoryResponseBody>(
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<backendModule.CreatedDirectory> {
const response = await this.post<backendModule.CreatedDirectory>(
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<backendModule.UpdatedDirectory>(
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<void> {
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<backendModule.ListedProject[]> {
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
const response = await this.get<ListProjectsResponseBody>(
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<backendModule.CreatedProject> {
const response = await this.post<backendModule.CreatedProject>(CREATE_PROJECT_PATH, body)
const response = await this.post<backendModule.CreatedProject>(
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<void> {
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<backendModule.Project> {
const response = await this.get<backendModule.ProjectRaw>(getProjectDetailsPath(projectId))
const response = await this.get<backendModule.ProjectRaw>(
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<void> {
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<backendModule.UpdatedProject> {
const response = await this.put<backendModule.UpdatedProject>(
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<backendModule.ResourceUsage> {
const response = await this.get<backendModule.ResourceUsage>(checkResourcesPath(projectId))
const response = await this.get<backendModule.ResourceUsage>(
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<backendModule.File[]> {
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
const response = await this.get<ListFilesResponseBody>(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<backendModule.FileInfo> {
const response = await this.postBinary<backendModule.FileInfo>(
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<backendModule.SecretAndInfo> {
const response = await this.post<backendModule.SecretAndInfo>(CREATE_SECRET_PATH, body)
const response = await this.post<backendModule.SecretAndInfo>(
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<backendModule.Secret> {
const response = await this.get<backendModule.Secret>(getSecretPath(secretId))
const response = await this.get<backendModule.Secret>(
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<backendModule.SecretInfo[]> {
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
const response = await this.get<ListSecretsResponseBody>(
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<backendModule.TagInfo> {
const response = await this.post<backendModule.TagInfo>(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<backendModule.TagInfo>(
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<backendModule.Tag[]> {
const response = await this.get<ListTagsResponseBody>(
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<void> {
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<backendModule.Version[]> {
const response = await this.get<ListVersionsResponseBody>(
LIST_VERSIONS_PATH +
remoteBackendPaths.LIST_VERSIONS_PATH +
'?' +
new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -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}`
}

View File

@ -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'

View File

@ -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 ===

View File

@ -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
}

View File

@ -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] ?? ''
}

View File

@ -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<T extends KnownEvent>(
) {
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

View File

@ -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 ? (
<React.StrictMode>
<App {...props} />
</React.StrictMode>

View File

@ -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'

View File

@ -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 => ({}), {})
}

View File

@ -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.

View File

@ -0,0 +1,10 @@
/** @file Global setup for tests. */
// =============
// === setup ===
// =============
/** Global setup for tests. */
export default function setup() {
process.env.NODE_ENV = 'production'
}

View File

@ -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<refresh.RefreshState>()
const onRefresh = (refreshState: refresh.RefreshState) => {
values.add(refreshState)
}
const component = await mount(<Refresh onRefresh={onRefresh} />)
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)
})

View File

@ -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 <div onClick={doRefresh}>.</div>
}

View File

@ -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;
}`)
}

View File

@ -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
},
}
}

View File

@ -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()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -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)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -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()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Some files were not shown because too many files have changed in this diff Show More