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)
1
.gitattributes
vendored
@ -1 +1,2 @@
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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`.
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
|
@ -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 ===
|
||||
// ================
|
||||
|
@ -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',
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
|
4
app/ide-desktop/lib/dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
@ -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',
|
||||
|
15
app/ide-desktop/lib/dashboard/log-screenshot-diffs.ts
Normal 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)
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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",
|
||||
|
48
app/ide-desktop/lib/dashboard/playwright-component.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
48
app/ide-desktop/lib/dashboard/playwright-e2e.config.ts
Normal 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,
|
||||
},
|
||||
})
|
8
app/ide-desktop/lib/dashboard/playwright.config.ts
Normal 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',
|
||||
})
|
12
app/ide-desktop/lib/dashboard/playwright/index.html
Normal 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>
|
1
app/ide-desktop/lib/dashboard/playwright/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
/** @file The file in which the test runner will append the built component code. */
|
@ -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 {
|
||||
|
@ -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 ===
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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. */
|
||||
|
@ -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 ===
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 ===
|
||||
// ===========
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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}. */
|
||||
|
@ -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'
|
||||
|
@ -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 ===
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
|
@ -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()
|
||||
}}
|
||||
|
@ -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'
|
||||
|
||||
// =====================
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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'
|
||||
|
@ -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' : ''
|
||||
}`}
|
||||
|
@ -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 => {
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ===
|
||||
|
@ -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 ===
|
||||
|
@ -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'
|
||||
|
@ -94,6 +94,7 @@ export default function UserBar(props: UserBarProps) {
|
||||
>
|
||||
<img
|
||||
src={DefaultUserIcon}
|
||||
alt="Open user menu"
|
||||
height={28}
|
||||
width={28}
|
||||
onDragStart={event => {
|
||||
|
@ -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()
|
||||
|
@ -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 ===
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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' }
|
||||
|
@ -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
|
||||
|
@ -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}`
|
||||
}
|
@ -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'
|
||||
|
@ -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 ===
|
||||
|
@ -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
|
||||
}
|
@ -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] ?? ''
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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 => ({}), {})
|
||||
}
|
@ -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.
|
||||
|
10
app/ide-desktop/lib/dashboard/src/test_setup.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/** @file Global setup for tests. */
|
||||
|
||||
// =============
|
||||
// === setup ===
|
||||
// =============
|
||||
|
||||
/** Global setup for tests. */
|
||||
export default function setup() {
|
||||
process.env.NODE_ENV = 'production'
|
||||
}
|
@ -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)
|
||||
})
|
@ -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>
|
||||
}
|
276
app/ide-desktop/lib/dashboard/test-e2e/actions.ts
Normal 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;
|
||||
}`)
|
||||
}
|
203
app/ide-desktop/lib/dashboard/test-e2e/api.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
@ -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()
|
||||
})
|
After Width: | Height: | Size: 54 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 48 KiB |
45
app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts
Normal 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)
|
||||
})
|
After Width: | Height: | Size: 50 KiB |
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 74 KiB |
After Width: | Height: | Size: 67 KiB |
After Width: | Height: | Size: 71 KiB |
22
app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts
Normal 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()
|
||||
})
|
After Width: | Height: | Size: 64 KiB |
After Width: | Height: | Size: 60 KiB |
After Width: | Height: | Size: 63 KiB |