diff --git a/AGENTS.md b/AGENTS.md index 66220d2ae..474184c52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,13 @@ This file defines autonomous and semi-autonomous agents used in this repository. - `yarn typecheck` - Run TypeScript type checking only - `yarn precommit` - Run lint and test (pre-commit hook) -### Cypress E2E Testing -- `yarn cy` - Run Cypress tests headlessly in Chrome -- `yarn cy-headed` - Run Cypress tests with UI -- `yarn cy-spec` - Run specific test spec -- `yarn cy-build` - Build for Cypress testing with MSW enabled -- `yarn cy-parallel` - Run tests in parallel for faster execution -- `yarn cy-percy` - Run visual regression tests with Percy +### Playwright E2E Testing +Playwright tests are run using the `yarn test-flows` command. Here are some examples. Paths and flags can be combined as well. + +- `yarn test-flows` - Run Playwright tests headlessly +- `yarn test-flows --ui --headed` - Run Playwright tests with UI and browser visible +- `yarn test-flows src/Components/Search/Search.spec.ts` - Run specific test spec +- `yarn test-flows --update-snapshots` - Update visual regression test snapshots ## Architecture Overview @@ -95,7 +95,7 @@ GitHub API integration for model versioning and collaboration: ### Build System - **ESBuild** configuration in `tools/esbuild/` - Dual build targets: Conway engine (default) and web-ifc -- Environment-specific configs: dev, prod, cypress +- Environment-specific configs: dev, prod, playwright - Multi-threading support with Conway engine - Bundle analysis and optimization tools diff --git a/src/Alerts.test.ts b/src/Alerts.test.ts new file mode 100644 index 000000000..ff61b282d --- /dev/null +++ b/src/Alerts.test.ts @@ -0,0 +1,242 @@ +import {BaseError, OutOfMemoryError} from './Alerts' + + +describe('BaseError', () => { + describe('toJSON', () => { + it('serializes a basic error with all fields', () => { + const error = new BaseError('Test message', { + title: 'Test Title', + description: 'Test Description', + action: 'Test Action', + actionUrl: '/test-url', + severity: 'error', + }) + + const json = error.toJSON() + + expect(json).toEqual({ + name: 'BaseError', + message: 'Test message', + title: 'Test Title', + description: 'Test Description', + action: 'Test Action', + actionUrl: '/test-url', + severity: 'error', + cause: undefined, + stack: expect.any(String), + }) + }) + + it('serializes an error with default fields', () => { + const error = new BaseError('Default message') + + const json = error.toJSON() + + expect(json).toEqual({ + name: 'BaseError', + message: 'Default message', + title: 'FooError', + description: 'Default message', + action: 'Reset', + actionUrl: '/', + severity: 'error', + cause: undefined, + stack: expect.any(String), + }) + }) + + it('serializes an error with an Error cause', () => { + const causeError = new Error('Cause error message') + const error = new BaseError('Test message', { + cause: causeError, + }) + + const json = error.toJSON() + + expect(json.cause).toEqual({ + name: 'Error', + message: 'Cause error message', + }) + expect(json.message).toBe('Test message') + }) + + it('serializes an error with a non-Error cause', () => { + const cause = {custom: 'cause object'} + const error = new BaseError('Test message', { + cause, + }) + + const json = error.toJSON() + + expect(json.cause).toBe(cause) + }) + + it('serializes a subclass error', () => { + const error = new OutOfMemoryError('Out of memory message') + + const json = error.toJSON() + + expect(json.name).toBe('OutOfMemoryError') + expect(json.message).toBe('Out of memory message') + expect(json.title).toBe('Out of Memory') + expect(json.description).toBe('Out of memory message') + expect(json.severity).toBe('error') + }) + }) + + describe('fromJSON', () => { + it('reconstructs an error from a JSON object with all fields', () => { + const json = { + name: 'BaseError', + message: 'Test message', + title: 'Test Title', + description: 'Test Description', + action: 'Test Action', + actionUrl: '/test-url', + severity: 'error' as const, + stack: 'Error: Test message\n at test.js:1:1', + } + + const error = BaseError.fromJSON(json) + + expect(error).toBeInstanceOf(BaseError) + expect(error.name).toBe('BaseError') + expect(error.message).toBe('Test message') + expect(error.title).toBe('Test Title') + expect(error.description).toBe('Test Description') + expect(error.action).toBe('Test Action') + expect(error.actionUrl).toBe('/test-url') + expect(error.severity).toBe('error') + expect(error.stack).toBe('Error: Test message\n at test.js:1:1') + }) + + it('reconstructs an error from a JSON object with minimal fields', () => { + const json = { + name: 'BaseError', + message: 'Minimal message', + } + + const error = BaseError.fromJSON(json) + + expect(error).toBeInstanceOf(BaseError) + expect(error.name).toBe('BaseError') + expect(error.message).toBe('Minimal message') + expect(error.title).toBe('FooError') + expect(error.description).toBe('Minimal message') + expect(error.action).toBe('Reset') + expect(error.actionUrl).toBe('/') + expect(error.severity).toBe('error') + }) + + it('reconstructs an error with a cause', () => { + const cause = {custom: 'cause object'} + const json = { + name: 'BaseError', + message: 'Test message', + cause, + } + + const error = BaseError.fromJSON(json) + + expect(error.cause).toBe(cause) + }) + + it('reconstructs an error with a cause Error object', () => { + const cause = {name: 'Error', message: 'Cause error message'} + const json = { + name: 'BaseError', + message: 'Test message', + cause, + } + + const error = BaseError.fromJSON(json) + + expect(error.cause).toEqual(cause) + }) + + it('handles missing name and uses default', () => { + const json = { + message: 'Test message', + } + + const error = BaseError.fromJSON(json) + + expect(error.name).toBe('BaseError') + }) + + it('handles missing message and uses default', () => { + const json = { + name: 'BaseError', + } + + const error = BaseError.fromJSON(json) + + expect(error.message).toBe('Error') + }) + }) + + describe('round-trip serialization', () => { + it('preserves all fields through toJSON and fromJSON', () => { + const original = new BaseError('Original message', { + title: 'Original Title', + description: 'Original Description', + action: 'Original Action', + actionUrl: '/original-url', + severity: 'error', + }) + + const json = original.toJSON() + const reconstructed = BaseError.fromJSON(json) + + expect(reconstructed.name).toBe(original.name) + expect(reconstructed.message).toBe(original.message) + expect(reconstructed.title).toBe(original.title) + expect(reconstructed.description).toBe(original.description) + expect(reconstructed.action).toBe(original.action) + expect(reconstructed.actionUrl).toBe(original.actionUrl) + expect(reconstructed.severity).toBe(original.severity) + expect(reconstructed.stack).toBe(original.stack) + }) + + it('preserves Error cause through round-trip', () => { + const causeError = new Error('Cause error message') + const original = new BaseError('Test message', { + cause: causeError, + }) + + const json = original.toJSON() + const reconstructed = BaseError.fromJSON(json) + + expect(reconstructed.cause).toEqual({ + name: 'Error', + message: 'Cause error message', + }) + }) + + it('preserves non-Error cause through round-trip', () => { + const cause = {custom: 'cause object', nested: {value: 123}} + const original = new BaseError('Test message', { + cause, + }) + + const json = original.toJSON() + const reconstructed = BaseError.fromJSON(json) + + expect(reconstructed.cause).toBe(cause) + }) + + it('preserves subclass error through round-trip', () => { + const original = new OutOfMemoryError('Out of memory message') + + const json = original.toJSON() + const reconstructed = BaseError.fromJSON(json) + + expect(reconstructed.name).toBe('OutOfMemoryError') + expect(reconstructed.message).toBe('Out of memory message') + expect(reconstructed.title).toBe('Out of Memory') + expect(reconstructed.description).toBe('Out of memory message') + expect(reconstructed.severity).toBe('error') + }) + }) +}) + diff --git a/src/Alerts.ts b/src/Alerts.ts new file mode 100644 index 000000000..92c06b94d --- /dev/null +++ b/src/Alerts.ts @@ -0,0 +1,265 @@ +/** + * Extend JS Error. All will be handled by AlertDialog. + * + * 0. throw new Error -> Hard reset (window.location.replace('/')) + * 1. throw new Exception -> Hard reset by default, optional fix action, eg clear cache. + * 2. throw new Alert -> Soft reset (navigate('/share/v/p/index.ifc')) + * 3. setAlert(new Info) -> OK: snackbar notification + * 4. setAlert(new Success) -> OK: dialog + */ + + +// Errors +/** Severity levels used by alert-like classes. */ +export type Severity = 'error' | 'warning' | 'info' | 'success' + +/** + * Base serializable error with helpers for structured clone and reconstruction. + */ +export class BaseError extends Error { + severity: Severity + name: string + title: string + description: string + action: string + actionUrl: string + /** Optional underlying error/cause */ + cause?: unknown + + /** + * @param message Error message + * @param opts Optional fields to override and/or include a cause + */ + constructor( + message: string, + opts?: { + title?: string, + description?: string, + action?: string, + actionUrl?: string, + cause?: unknown, + severity?: Severity, + }) { + // Pass cause to super so native chaining works + // (OK in Node 16.9+/Chromium 93+; harmless elsewhere) + if (opts?.cause !== undefined) { + // @ts-expect-error - Error constructor with options.cause is supported in Node 16.9+/Chromium 93+ + super(message, {cause: opts.cause}) + } else { + super(message) + } + this.severity = 'error' + this.name = new.target.name + this.title = opts?.title || 'FooError' + this.description = opts?.description || message + this.action = opts?.action || 'Reset' + this.actionUrl = opts?.actionUrl || '/' + this.cause = opts?.cause + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BaseError) + } + } + + /** + * Serialize the error to a JSON object. + * + * @return The JSON object. + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + title: this.title, + description: this.description, + action: this.action, + actionUrl: this.actionUrl, + severity: this.severity, + cause: this.cause instanceof Error ? {name: this.cause.name, message: String(this.cause.message)} : this.cause, + stack: this.stack, + } + } + + + /** + * Reconstruct an error from a JSON object. + * + * @param obj - The JSON object to reconstruct the error from. + * @return The reconstructed error. + */ + static fromJSON(obj: Record): BaseError { + const err = new BaseError(String(obj?.message ?? 'Error'), { + title: typeof obj?.title === 'string' ? obj.title : undefined, + description: typeof obj?.description === 'string' ? obj.description : undefined, + action: typeof obj?.action === 'string' ? obj.action : undefined, + actionUrl: typeof obj?.actionUrl === 'string' ? obj.actionUrl : undefined, + cause: obj?.cause, + severity: obj?.severity as Severity | undefined, + }) + err.name = String(obj?.name ?? 'BaseError') + err.stack = typeof obj?.stack === 'string' ? obj.stack : err.stack + return err + } +} + + +/** + * Error with a reset action for user. + * + * NB: Subclass this class to provide a custom title, description, and action. + */ +export class Exception extends BaseError { + /** + * @param message Error message + */ + constructor( + message = 'Exception occurred. Please reset the application and try again.', + opts: { cause?: unknown } = {}, + ) { + super(message, { + severity: 'error', + title: 'Exception', + description: 'An exception occurred. Please reset the application and try again.', + action: 'Reset', + actionUrl: '/', + cause: opts?.cause, + }) + } +} + + +// Alerts +/** + * Alert message for user. + * + * NB: Subclass this class to provide a custom title, description, and action. + */ +export class Alert { + name: string + message: string + severity: 'alert' + title: string + description: string + action: string + actionUrl: string + + /** + * @param message Error message + * @param opts Optional fields to override and/or include a cause + */ + constructor( + message = 'Alert message occurred. Please check the application and try again.', + opts: { + title?: string, + description?: string, + action?: string, + actionUrl?: string, + cause?: unknown, + } = { + title: 'Alert', + description: message, + action: 'Reset', + actionUrl: '/share/v/p/index.ifc', + }, + ) { + this.name = new.target.name + this.message = message + this.severity = 'alert' + this.title = opts.title ?? 'Alert' + this.description = opts.description ?? message + this.action = opts.action ?? 'Reset' + this.actionUrl = opts.actionUrl ?? '/share/v/p/index.ifc' + } +} + + +// Info +/** + * Info message for user. + * + * NB: Subclass this class to provide a custom title, description, and action. + */ +export class Info { + name: string + message: string + severity: 'info' + title: string + description: string + action: string + actionUrl: string + cause?: unknown + + /** + * @param message Error message + * @param opts Optional fields to override and/or include a cause + */ + constructor( + message = 'Info message occurred. Please check the application and try again.', + opts: { + title?: string, + description?: string, + action?: string, + actionUrl?: string, + cause?: unknown, + } = { + title: 'Info', + description: message, + action: 'Reset', + actionUrl: '/', + }, + ) { + this.name = new.target.name + this.message = message + this.severity = 'info' + this.title = opts.title ?? 'Info' + this.description = opts.description ?? message + this.action = opts.action ?? 'Reset' + this.actionUrl = opts.actionUrl ?? '/' + } +} + + +/** SpecificErrors */ + +/** + * Error when the application runs out of memory. + * + * @augments BaseError + */ +export class OutOfMemoryError extends BaseError { + /** @param message Error message */ + constructor( + message = 'The application ran out of memory. Please reset the application and try again.', + opts: { cause?: unknown } = {}, + ) { + super(message, { + severity: 'error', + title: 'Out of Memory', + description: message, + cause: opts?.cause, + }) + } +} + + +/** + * Indicates streaming reads/writes are not supported in the current environment. + * Used when Response.body is unavailable and streaming OPFS writes cannot be performed. + * + * @augments BaseError + */ +export class FileStreamingUnsupported extends BaseError { + /** + * @param message Error message + */ + constructor( + message = 'Streaming not supported. Please reset the application and try again.', + opts: { cause?: unknown } = {}, + ) { + super(message, { + severity: 'error', + title: 'Streaming not supported', + description: message, + cause: opts?.cause, + }) + } +} diff --git a/src/Components/AlertDialog.jsx b/src/Components/AlertDialog.jsx index e95bdba18..18b90c735 100644 --- a/src/Components/AlertDialog.jsx +++ b/src/Components/AlertDialog.jsx @@ -1,10 +1,12 @@ import React, {ReactElement} from 'react' -import {Link} from '@mui/material' -import {ErrorOutline as ErrorOutlineIcon} from '@mui/icons-material' -import {NotFoundError} from '../loader/Loader' +import {Helmet} from 'react-helmet-async' +import Markdown from 'react-markdown' +import {useNavigate} from 'react-router-dom' +import {Alert, Link} from '@mui/material' import useStore from '../store/useStore' import {trackAlert} from '../utils/alertTracking' import Dialog from './Dialog' +import {ErrorOutline as ErrorOutlineIcon} from '@mui/icons-material' /** @@ -16,93 +18,70 @@ import Dialog from './Dialog' export default function AlertDialog({onClose}) { const alert = useStore((state) => state.alert) const setAlert = useStore((state) => state.setAlert) + const navigate = useNavigate() + + const severity = alert?.severity || 'error' + const severityTitle = severity.charAt(0).toUpperCase() + severity.slice(1) + const name = alert?.name || 'Error' + const alertTitle = alert?.title || 'Error' + const description = ( + typeof alert === 'string' ? + alert : (alert?.description || alert?.message || 'An error occurred. Please reset the application and try again.') + ) + const actionTitle = alert?.actionTitle || 'Reset' + const actionUrl = alert?.actionUrl || '/' + + trackAlert(name, alert) const onCloseInner = () => { setAlert(null) - onClose() + if (actionUrl === '/') { + window.location.replace('/') + } else if (actionUrl) { + navigate(actionUrl) + } else { + onClose(alert) + } } - const isOom = alert && typeof alert === 'object' && alert.type === 'oom' - const refresh = () => { - try { - window.location.reload() - } catch (_) {/* noop */} - } - const actionCb = isOom ? refresh : onCloseInner - const actionTitle = isOom ? 'Refresh' : 'Reset' return ( } actionTitle={actionTitle} > + + {severityTitle} + + + + ( + + {children} + + ), + }} + > + {description} + +

- {createAlertReport(alert)}
- {!isOom && ( - <>For more help contact us on our{' '} - - Discord - {' '} - for help - - )} + For more help contact us on our{' '} + + Discord + {' '} + for help.

) } - - -/** - * @param {object} a - * @return {ReactElement} - */ -function createAlertReport(a) { - if (a === null) { - return '' - } - if (typeof a === 'string') { - trackAlert(a) - return a - } else if (typeof a === 'object') { - if (a && a.type === 'oom') { - trackAlert(a.message, a) - return a.message - } else if (a instanceof NotFoundError) { - trackAlert(a.message, a) - return displayPathAlert(a) - } else if (a instanceof Error) { - console.error('General error:', a) - trackAlert(a.message, a) - return `${a}` - } - } - return '' -} - - -/** - * @param {object} alert - * @return {ReactElement} - */ -function displayPathAlert(alert) { - return ( -

- Check the file path:
- {alert && insertZeroWidthSpaces(alert)} -

- ) -} - - -/** - * Insert the spaces after / _ character to make sure the string breaks correctly - * - * @param {string} str error path, usually a long string - * @return {string} formatted string - */ -const insertZeroWidthSpaces = (str) => { - return str.replace(/([/_-])/g, '$1\u200B') -} diff --git a/src/Components/AlertDialog.test.jsx b/src/Components/AlertDialog.test.jsx new file mode 100644 index 000000000..189a2b546 --- /dev/null +++ b/src/Components/AlertDialog.test.jsx @@ -0,0 +1,201 @@ +import React from 'react' +import '@testing-library/jest-dom' +import {act, fireEvent, render, renderHook, waitFor} from '@testing-library/react' +import {HelmetStoreRouteThemeCtx} from '../Share.fixture' +import AlertDialog from './AlertDialog' +import useStore from '../store/useStore' +import {OutOfMemoryError} from '../Alerts' + + +// Mock trackAlert to assert it is called +jest.mock('../utils/alertTracking', () => ({ + trackAlert: jest.fn(), +})) + +// Mock useNavigate to track navigation calls +const mockNavigate = jest.fn() +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})) + +describe('AlertDialog', () => { + let locationGetter + + beforeEach(() => { + const mockLocation = { + replace: jest.fn(), + } + locationGetter = jest.spyOn(window, 'location', 'get').mockReturnValue(mockLocation) + mockNavigate.mockClear() + }) + + afterEach(() => { + if (locationGetter) { + locationGetter.mockRestore() + } + }) + + + test('renders title, description (markdown) and calls trackAlert', async () => { + const {trackAlert} = await import('../utils/alertTracking') + const alert = { + name: 'LoadError', + title: 'Failed to load model', + description: 'Please see [docs](https://example.com) for help.', + } + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert(alert) + }) + + const cb = jest.fn() + const {getByRole, getByTestId} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + expect(trackAlert).toHaveBeenCalledWith(alert.name, alert) + + await waitFor(() => expect(document.title).toBe('Error')) + + const dialog = getByRole('dialog') + expect(dialog).toHaveTextContent(alert.title) + + fireEvent.click(getByTestId('button-dialog-main-action')) + expect(window.location.replace).toHaveBeenCalledWith('/') + }) + + + test('AlertDialog appears when alert is set with string error', async () => { + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert('Test error message') + }) + + const cb = jest.fn() + const {getByRole, getByText, getByTestId} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + expect(getByRole('heading', {name: 'Error'})).toBeVisible() + expect(getByText('Test error message')).toBeVisible() + expect(getByTestId('button-dialog-main-action')).toBeVisible() + expect(getByTestId('button-dialog-main-action')).toHaveTextContent('Reset') + }) + + + test('AlertDialog can be closed via close button', async () => { + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert('Test error message') + }) + + const cb = jest.fn() + const {getByRole, getByTestId, queryByRole} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + + fireEvent.click(getByTestId('button-close-dialog-error')) + await waitFor(() => { + expect(queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + + test('AlertDialog can be closed via Reset button', async () => { + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert('Test error message') + }) + + const cb = jest.fn() + const {getByRole, getByTestId, queryByRole} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + + fireEvent.click(getByTestId('button-dialog-main-action')) + await waitFor(() => { + expect(queryByRole('dialog')).not.toBeInTheDocument() + }) + }) + + + test('AlertDialog showing a specific OOM alert, from WASM alloc issue, with Reset button', async () => { + const description = 'WASM memory allocation failed' + const oomError = new OutOfMemoryError(description) + + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert(oomError) + }) + + const cb = jest.fn() + const {getByRole, getByTestId} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + expect(getByRole('heading', {name: 'Out of Memory'})).toBeVisible() + expect(dialog).toHaveTextContent(description) + expect(getByTestId('button-dialog-main-action')).toHaveTextContent('Reset') + expect(getByTestId('button-close-dialog-out-of-memory')).toBeVisible() + }) + + + test('AlertDialog navigates to default route when closed', async () => { + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert('Test error message') + }) + + const cb = jest.fn() + const {getByRole, getByTestId, queryByRole} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + + fireEvent.click(getByTestId('button-dialog-main-action')) + await waitFor(() => { + expect(queryByRole('dialog')).not.toBeInTheDocument() + }) + // Should navigate to default route (/) via window.location.replace + expect(window.location.replace).toHaveBeenCalledWith('/') + }) + + + test('AlertDialog handles plain Error object with message property', async () => { + const plainError = new Error('Plain error message') + + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert(plainError) + }) + + const cb = jest.fn() + const {getByRole, getByText} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + expect(getByText('Plain error message')).toBeVisible() + }) + + + test('AlertDialog handles Error object with description property', async () => { + const errorWithDescription = { + name: 'CustomError', + title: 'Custom Error', + description: 'Custom error description', + } + + const {result} = renderHook(() => useStore((state) => state)) + await act(() => { + result.current.setAlert(errorWithDescription) + }) + + const cb = jest.fn() + const {getByRole, getByText} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + + const dialog = getByRole('dialog') + expect(dialog).toBeVisible() + expect(getByText('Custom error description')).toBeVisible() + }) +}) diff --git a/src/Components/Dialog.jsx b/src/Components/Dialog.jsx index 08ed0e3ad..be8c18ddd 100644 --- a/src/Components/Dialog.jsx +++ b/src/Components/Dialog.jsx @@ -1,10 +1,9 @@ import React, {ReactElement} from 'react' -import {Button, Dialog as MuiDialog, DialogActions, DialogContent, DialogTitle, Typography} from '@mui/material' +import {Button, Dialog as MuiDialog, DialogActions, DialogContent, DialogTitle} from '@mui/material' import useStore from '../store/useStore' import {assertDefined, assertString} from '../utils/assert' import {slugify} from '../utils/strings' import {CloseButton} from './Buttons' -import {useIsMobile} from './Hooks' /** @@ -47,7 +46,6 @@ export default function Dialog({ } const onCloseClick = () => setIsDialogDisplayed(false) const dataTestIdSuffix = slugify(headerText) - const isMobile = useIsMobile() return ( {headerIcon && headerIcon} + {headerText} - {headerText} {children} {actionTitle === undefined ? null : diff --git a/src/Components/Open/Filetypes.spec.ts b/src/Components/Open/Filetypes.spec.ts index 8d60fcbbe..2e6d696ad 100644 --- a/src/Components/Open/Filetypes.spec.ts +++ b/src/Components/Open/Filetypes.spec.ts @@ -1,14 +1,10 @@ import {Page, test} from '@playwright/test' -import { - homepageSetup, - setIsReturningUser, -} from '../../tests/e2e/utils' -import {setupVirtualPathIntercept, waitForModelReady} from '../../tests/e2e/models' +import {homepageSetup, setIsReturningUser} from '../../tests/e2e/utils' +import {setupGithubPathIntercept} from '../../tests/e2e/models' import {expectScreen} from '../../tests/screens' const {beforeEach, describe} = test - /** * Tests for opening models in multiple file formats. * Tests support for various 3D model file formats (FBX, OBJ, STL, STEP, STP). @@ -18,28 +14,32 @@ const {beforeEach, describe} = test * @see https://github.com/bldrs-ai/Share/issues/757 */ describe('Open 200: Open Models in multiple formats', () => { - /** - * @property {string} urlPath Path user would provide at GitHub - * @property {string} filePath Actual fixture to use - * @property {string} debugTag debugging name for intercept - */ - async function doTest({page, urlPath, filePath, debugTag}: {page: Page, urlPath: string, filePath: string, debugTag: string}) { - await setupVirtualPathIntercept(page, urlPath, filePath) - await page.goto(urlPath) - await waitForModelReady(page) - await expectScreen(page, `Filetypes-${debugTag}.png`) - } - beforeEach(async ({page}) => { await homepageSetup(page) await setIsReturningUser(page.context()) }) + type DoTestParams = { + page: Page + githubPathname: string + gotoPathname: string + fixturePath: string + debugTag: string + } + + /** Test helper for opening models in different file formats. */ + async function doTest({page, githubPathname, gotoPathname, fixturePath, debugTag}: DoTestParams): Promise { + const waitForModelReadyCallback = await setupGithubPathIntercept(page, githubPathname, gotoPathname, fixturePath) + await waitForModelReadyCallback() + await expectScreen(page, `Filetypes-${debugTag}.png`) + } + test('Loads FBX - Screen', async ({page}) => { await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/fbx/samba-dancing.fbx', - filePath: 'test-models/fbx/samba-dancing.fbx', + githubPathname: '/bldrs-ai/test-models/main/fbx/samba-dancing.fbx', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/fbx/samba-dancing.fbx', + fixturePath: 'test-models/fbx/samba-dancing.fbx', debugTag: 'fbxLoad', }) }) @@ -47,8 +47,9 @@ describe('Open 200: Open Models in multiple formats', () => { test('Loads OBJ - Screen', async ({page}) => { await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/obj/Bunny.obj', - filePath: 'test-models/obj/Bunny.obj', + githubPathname: '/bldrs-ai/test-models/main/obj/Bunny.obj', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/obj/Bunny.obj', + fixturePath: 'test-models/obj/Bunny.obj', debugTag: 'objLoad', }) }) @@ -56,8 +57,9 @@ describe('Open 200: Open Models in multiple formats', () => { test('Loads STL (test) - Screen', async ({page}) => { await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/stl/slotted_disk.stl', - filePath: 'test-models/stl/slotted_disk.stl', + githubPathname: '/bldrs-ai/test-models/main/stl/slotted_disk.stl', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/stl/slotted_disk.stl', + fixturePath: 'test-models/stl/slotted_disk.stl', debugTag: 'stlTextLoad', }) }) @@ -65,27 +67,30 @@ describe('Open 200: Open Models in multiple formats', () => { test('Loads STL (binary) - Screen', async ({page}) => { await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/stl/pr2_head_pan.stl', - filePath: 'test-models/stl/pr2_head_pan.stl', + githubPathname: '/bldrs-ai/test-models/main/stl/pr2_head_pan.stl', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/stl/pr2_head_pan.stl', + fixturePath: 'test-models/stl/pr2_head_pan.stl', debugTag: 'stlBinaryLoad', }) }) - test.skip('Loads STEP - Screen', async ({page}) => { + test('Loads STEP - Screen', async ({page}) => { await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/step/gear.step', - filePath: 'test-models/step/gear.step', + githubPathname: '/bldrs-ai/test-models/main/step/gear.step', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/step/gear.step', + fixturePath: 'test-models/step/gear.step', debugTag: 'stepLoad', }) }) - test.skip('Loads STP - Screen', async ({page}) => { + test('Loads STP - Screen', async ({page}) => { // Use same actual local file, just testing .stp handling await doTest({ page, - urlPath: '/share/v/gh/bldrs-ai/test-models/main/step/gear.stp', - filePath: 'test-models/step/gear.step', + githubPathname: '/bldrs-ai/test-models/main/step/gear.stp', + gotoPathname: '/share/v/gh/bldrs-ai/test-models/main/step/gear.stp', + fixturePath: 'test-models/step/gear.step', debugTag: 'stpLoad', }) }) diff --git a/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stepLoad.png b/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stepLoad.png new file mode 100644 index 000000000..d3d34f038 Binary files /dev/null and b/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stepLoad.png differ diff --git a/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stpLoad.png b/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stpLoad.png new file mode 100644 index 000000000..d3d34f038 Binary files /dev/null and b/src/Components/Open/Filetypes.spec.ts-snapshots/Filetypes-stpLoad.png differ diff --git a/src/Components/Open/GitHubFileBrowser.jsx b/src/Components/Open/GitHubFileBrowser.jsx index 1ff4ca4fb..163363994 100644 --- a/src/Components/Open/GitHubFileBrowser.jsx +++ b/src/Components/Open/GitHubFileBrowser.jsx @@ -128,7 +128,7 @@ export default function GitHubFileBrowser({ } } return ( - + Browse files on Github { describe('Open Project From GitHub Link', () => { beforeEach(async ({page}) => { await homepageSetup(page) + await setIsReturningUser(page.context()) + await visitHomepageWaitForModel(page) }) describe('Returning user visits homepage, enters Model URL into search', () => { + const githubPathname = '/bldrs-ai/test-models/main/ifc/misc/box.ifc' + let waitForModelReadyCallback: () => Promise beforeEach(async ({page}) => { - await setIsReturningUser(page.context()) - await visitHomepageWaitForModel(page) - await page.getByTestId('control-button-search').click() - await setupVirtualPathIntercept( + waitForModelReadyCallback = await setupGithubPathIntercept( page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', - '/Momentum.ifc', + githubPathname, + undefined, // we're initiating the navigation below, so no auto-navigate needed + 'test-models/ifc/misc/box.ifc', ) - // Note this includes {enter} at end to simulate Enter keypress - await page.getByTestId('textfield-search-query') - .fill('https://github.com/Swiss-Property-AG/Momentum-Public/blob/main/Momentum.ifc') - await page.getByTestId('textfield-search-query').press('Enter') }) // TODO(https://github.com/bldrs-ai/Share/issues/1269): fix and re-enable - test.skip('Model loads - Screen', async ({page}) => { - await waitForModelReady(page) + test('Model loads - Screen', async ({page}) => { + await page.getByTestId('control-button-open').click() + // Note this includes {enter} at end to simulate Enter keypress + const searchInput = page.getByRole('textbox', {name: GITHUB_SEARCH_BAR_PLACEHOLDER_TEXT}) + await searchInput.fill(`https://github.com${githubPathname}`) + await waitForModelReadyCallback() await expectScreen(page, 'Github-link-model-loaded.png') }) }) }) - describe('Open model from GitHub via UI', () => { + describe.skip('Open model from GitHub via UI', () => { beforeEach(async ({page}) => { await homepageSetup(page) }) @@ -63,10 +66,11 @@ describe('Open 100: GitHub Integration', () => { await returningUserVisitsHomepageWaitForModel(page) await auth0Login(page) // set up initial index.ifc load - await setupVirtualPathIntercept( + await setupGithubPathIntercept( page, - '/share/v/gh/cypresstester/test-repo/main/window.ifc', - '/index.ifc', + '/bldrs-ai/test-models/main/ifc/misc/box.ifc', + undefined, // we're initiating the navigation below, so no auto-navigate needed + 'box.ifc', ) }) diff --git a/src/Components/Open/Github.spec.ts-snapshots/Github-link-model-loaded.png b/src/Components/Open/Github.spec.ts-snapshots/Github-link-model-loaded.png new file mode 100644 index 000000000..2569b270a Binary files /dev/null and b/src/Components/Open/Github.spec.ts-snapshots/Github-link-model-loaded.png differ diff --git a/src/Components/Open/OpenModelDialog.jsx b/src/Components/Open/OpenModelDialog.jsx index 2e2f4508a..e4a3424d5 100644 --- a/src/Components/Open/OpenModelDialog.jsx +++ b/src/Components/Open/OpenModelDialog.jsx @@ -13,7 +13,7 @@ import Tabs from '../Tabs' import GitHubFileBrowser from './GitHubFileBrowser' import PleaseLogin from './PleaseLogin' import SampleModels from './SampleModels' -import {LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES} from './component' +import {GITHUB_SEARCH_BAR_PLACEHOLDER_TEXT, LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES} from './component' import {FolderOpen as FolderOpenIcon} from '@mui/icons-material' @@ -74,7 +74,7 @@ export default function OpenModelDialog({ justifyContent: 'center', alignItems: 'center', }} - data-testid={`dialog-open-model-tabs-stack`} + data-testid='dialog-open-model-tabs-stack' > { currentTab === 0 && @@ -99,9 +99,9 @@ export default function OpenModelDialog({ } { currentTab === 1 && - + { const ghPath = event.target.value diff --git a/src/Components/Open/OpenModelDialog.spec.ts b/src/Components/Open/OpenModelDialog.spec.ts index 27c55343b..1f8b9442a 100644 --- a/src/Components/Open/OpenModelDialog.spec.ts +++ b/src/Components/Open/OpenModelDialog.spec.ts @@ -1,10 +1,8 @@ import {expect, test} from '@playwright/test' import { - auth0Login, homepageSetup, returningUserVisitsHomepageWaitForModel, } from '../../tests/e2e/utils' -import {setupVirtualPathIntercept, waitForModelReady} from '../../tests/e2e/models' import {expectScreen} from '../../tests/screens' @@ -44,36 +42,4 @@ describe('Open 100: Open model dialog', () => { // w/system dialog but can't get it working in cypress. Need to get the fix // checked in (#1361), so punting for now. }) - - describe('Returning user visits homepage logged in', () => { - beforeEach(async ({page}) => { - await returningUserVisitsHomepageWaitForModel(page) - await setupVirtualPathIntercept( - page, - '/share/v/gh/cypresstester/test-repo/main/window.ifc', - '/index.ifc', - ) - await auth0Login(page) - await page.getByTestId('control-button-open').click() - }) - - test.skip('GitHub controls are visible', async ({page}) => { - await page.getByTestId('tab-github').click() - await expectScreen(page, 'OpenModelDialog-github-tab.png') - }) - - test.skip('Choose the path to the model on GitHub -> model is loaded into the scene', async ({page}) => { - await page.getByTestId('tab-github').click() - await page.getByText('Browse files on Github').click() - await page.getByRole('textbox', {name: 'Organization'}).click() - await page.getByText('@cypresstester').click() - await page.getByRole('textbox', {name: 'Repository'}).first().click() - await page.getByText('test-repo').click() - await page.getByRole('textbox', {name: 'File'}).first().click() - await page.getByText('window.ifc').click() - await page.getByTestId('button-openfromgithub').click() - await waitForModelReady(page) - await expectScreen(page, 'OpenModelDialog-github-model-loaded.png') - }) - }) }) diff --git a/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-local-tab.png b/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-local-tab.png index 05e460b49..3f7924339 100644 Binary files a/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-local-tab.png and b/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-local-tab.png differ diff --git a/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-samples-tab.png b/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-samples-tab.png index 9614a399c..3f011153c 100644 Binary files a/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-samples-tab.png and b/src/Components/Open/OpenModelDialog.spec.ts-snapshots/OpenModelDialog-samples-tab.png differ diff --git a/src/Components/Open/SampleModels.jsx b/src/Components/Open/SampleModels.jsx index 502fb61e3..a32e28893 100644 --- a/src/Components/Open/SampleModels.jsx +++ b/src/Components/Open/SampleModels.jsx @@ -61,7 +61,7 @@ export default function SampleModels({navigate, setIsDialogDisplayed}) { justifyContent='center' alignItems='center' sx={stackSx} - data-testid={`dialog-open-model-samples`} + data-testid='dialog-open-model-samples' > {Object.keys(modelPath).map((model, i) => ( diff --git a/src/Components/Open/Samples.spec.ts b/src/Components/Open/Samples.spec.ts index cb449901c..577f133b7 100644 --- a/src/Components/Open/Samples.spec.ts +++ b/src/Components/Open/Samples.spec.ts @@ -1,13 +1,10 @@ -import {expect, Route, test} from '@playwright/test' -import {readFile} from 'fs/promises' -import path from 'path' +import {expect, test} from '@playwright/test' import { homepageSetup, setIsReturningUser, visitHomepageWaitForModel, } from '../../tests/e2e/utils' -import {setupVirtualPathIntercept, waitForModelReady} from '../../tests/e2e/models' -import {expectScreen} from '../../tests/screens' +import {setupGithubPathIntercept} from '../../tests/e2e/models' const {beforeEach, describe} = test @@ -40,167 +37,40 @@ describe('Sample models', () => { await expect(page.getByRole('dialog')).toContainText('Samples') }) - test('Should intercept model requests successfully', async ({page}) => { - // Set up the intercept before navigating - await setupVirtualPathIntercept( - page, - '/share/v/gh/bldrs-ai/test-models/main/ifc/misc/box.ifc', - 'box.ifc', - ) - - // Navigate using page hash to avoid direct file navigation - // await page.goto('/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc') - await page.goto('/share/v/gh/bldrs-ai/test-models/main/ifc/misc/box.ifc') - - // Wait for model to be ready (any model, even if it's the fallback) - await waitForModelReady(page) - - // Basic verification - a model loaded successfully - // Verify that some model is loaded (data-model-ready=true was achieved) - const dropzone = page.getByTestId('cadview-dropzone') - await expect(dropzone).toHaveAttribute('data-model-ready', 'true') - }) - }) - - describe.skip('When no model is loaded', () => { - beforeEach(async ({page}) => { - await homepageSetup(page) - await setIsReturningUser(page.context()) - await page.goto('/') - // Wait for viewer container and canvas to be visible - const viewerContainer = page.locator('#viewer-container') - await expect(viewerContainer).toBeVisible() - const canvas = viewerContainer.locator('canvas') - await expect(canvas).toBeVisible() - // Wait for model ready attribute - const dropzone = page.getByTestId('cadview-dropzone') - await expect(dropzone).toHaveAttribute('data-model-ready', 'true') - }) - - test('should display tooltip when hovering', async ({page}) => { - await page.getByRole('button', {name: 'Open IFC'}).hover() - await expect(page.getByRole('tooltip')).toContainText('Open IFC') - }) - test('should display the sample models dialog', async ({page}) => { await page.getByTestId('control-button-open').click() - await expect(page.getByRole('dialog')).toContainText('Sample Projects') + await expect(page.getByRole('dialog')).toContainText('Samples') }) test('should load the Momentum model when selected', async ({page}) => { - // Set up intercept for Momentum.ifc using TestFixture.ifc - await page.route('**/Momentum.ifc', async (route) => { - const fixturePath = path.resolve(process.cwd(), 'src/tests/fixtures/TestFixture.ifc') - const fixtureBuffer = await readFile(fixturePath) - - await route.fulfill({ - status: 200, - body: fixtureBuffer, - headers: {'content-type': 'application/octet-stream'}, - }) - }) + const waitForModelReadyCallback = await setupGithubPathIntercept( + page, + '/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', + '/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', + 'test-models/ifc/Momentum.ifc', + ) + await waitForModelReadyCallback() await page.getByTestId('control-button-open').click() - await page.getByRole('tab', {name: 'Sample Projects'}).click() + await page.getByRole('tab', {name: 'Samples'}).click() // Wait for listbox to appear and select Momentum - const listbox = page.getByRole('listbox') - await expect(listbox).toBeVisible() - - await listbox.getByRole('option', {name: 'Momentum'}).click() - - // Wait for listbox to disappear - await expect(listbox).toHaveCount(0) - - // Verify IFC Navigator appears - await expect(page.getByRole('tree', {name: 'IFC Navigator'})).toBeVisible() - - // Verify specific text appears (from the loaded model) - await expect(page.getByText('Proxy with extruded box')).toBeVisible() - }) - }) + const samplesGrid = page.getByTestId('dialog-open-model-samples') + await expect(samplesGrid).toBeVisible() - // Additional tests from open-sample-model.cy.js - describe.skip('Open 100: Open Sample Model', () => { - describe('Returning user visits homepage', () => { - beforeEach(async ({page}) => { - await homepageSetup(page) - await setIsReturningUser(page.context()) - await visitHomepageWaitForModel(page) - }) + await samplesGrid.getByTestId('sample-model-chip-0').click() - describe('Select OpenModelControl > Sample Models', () => { - beforeEach(async ({page}) => { - await page.getByTestId('control-button-open').click() - await page.getByTestId('tab-samples').click() - }) + // Wait for canvas data-model-ready attribute to be true + await expect(page.getByTestId('cadview-dropzone')).toHaveAttribute('data-model-ready', 'true') - test('Sample project list appears, including Momentum etc. - Screen', async ({page}) => { - await expectScreen(page, 'Samples-project-list.png') - }) + // Open NavTree + await page.getByTestId('control-button-navigation').click() - describe('Choose one of the projects from the list', () => { - beforeEach(async ({page}) => { - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', - '/Momentum.ifc', - ) - await page.getByText('Momentum').click() - await waitForModelReady(page) - }) + // Wait for NavTree to appear + await expect(page.getByTestId('SideDrawerPanel-Paper-Navigation')).toBeVisible() - test('Project loads - Screen', async ({page}) => { - await expectScreen(page, 'Samples-momentum-loaded.png') - }) - }) - }) - - describe('Open up all persistent controls', () => { - beforeEach(async ({page}) => { - // Select element, opens nav - await page.route('**/share/v/p/index.ifc/81/621', async (route: Route) => { - const fixturePath = path.resolve(process.cwd(), 'src/tests/fixtures/404.html') - const fixtureBuffer = await readFile(fixturePath) - await route.fulfill({ - status: 200, - body: fixtureBuffer, - headers: {'content-type': 'text/html'}, - }) - }) - await page.goto('/share/v/p/index.ifc/81/621') - await waitForModelReady(page) - - // Open properties - await page.getByTestId('control-button-properties').click() - - // Open notes - await page.getByTestId('control-button-notes').click() - - // Open search - await page.getByTestId('control-button-search').click() - - // Add a cutplane - await page.getByTestId('control-button-cut-plane').click() - await expect(page.getByTestId('menu-cut-plane')).toBeVisible() - await page.getByTestId('menu-item-plan').click() - - // Select a sample project - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', - '/Momentum.ifc', - ) - await page.getByTestId('control-button-open').click() - await page.getByTestId('tab-samples').click() - await page.getByText('Momentum').click() - await waitForModelReady(page) - }) - - test('Project loads, all controls reset - Screen', async ({page}) => { - await expectScreen(page, 'Samples-all-controls-reset.png') - }) - }) + // Verify specific text appears (from the loaded model) + await expect(page.getByText('Momentum / KNIK v3')).toBeVisible() }) }) }) diff --git a/src/Components/Open/component.js b/src/Components/Open/component.js index a6f393067..404fe6db7 100644 --- a/src/Components/Open/component.js +++ b/src/Components/Open/component.js @@ -1,3 +1,4 @@ export const LABEL_GITHUB = 'GitHub' export const LABEL_LOCAL = 'Local' export const LABEL_SAMPLES = 'Samples' +export const GITHUB_SEARCH_BAR_PLACEHOLDER_TEXT = 'Enter GitHub model URL...' diff --git a/src/Components/Search/Search.spec.ts b/src/Components/Search/Search.spec.ts index d307b915b..0c24f811c 100644 --- a/src/Components/Search/Search.spec.ts +++ b/src/Components/Search/Search.spec.ts @@ -4,7 +4,7 @@ import { returningUserVisitsHomepageWaitForModel, setIsReturningUser, } from '../../tests/e2e/utils' -import {waitForModelReady, setupVirtualPathIntercept} from '../../tests/e2e/models' +import {setupGithubPathIntercept, setupGoogleDrivePathIntercept, waitForModelReady} from '../../tests/e2e/models' import {SEARCH_BAR_PLACEHOLDER_TEXT} from './component' import {expectScreen} from '../../tests/screens' @@ -51,21 +51,55 @@ describe('Search 100', () => { }) describe('with GitHub link to box.ifc', () => { + let waitForModelReadyCallback: () => Promise beforeEach(async ({page}) => { - await setupVirtualPathIntercept( + waitForModelReadyCallback = await setupGithubPathIntercept( page, - '/share/v/gh/bldrs-ai/test-models/main/ifc/misc/box.ifc', - 'box.ifc', + '/bldrs-ai/test-models/main/ifc/misc/box.ifc', + undefined, // we're initiating the navigation below, so no auto-navigate needed + 'test-models/ifc/misc/box.ifc', ) + }) + + test('box.ifc loads - Screen', async ({page}) => { + await page.getByTestId('control-button-search').click() + const searchInput = page.getByPlaceholder(SEARCH_BAR_PLACEHOLDER_TEXT) + await searchInput.fill('https://github.com/bldrs-ai/test-models/blob/main/ifc/misc/box.ifc') + await searchInput.press('Enter') // initiate navigation + await waitForModelReadyCallback() + await expectScreen(page, 'box-github-link-loaded.png') + }) + }) + + describe('with Google Drive link', () => { + const fileId = '1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO' + let waitForModelReadyCallback: () => Promise + beforeEach(async ({page}) => { + waitForModelReadyCallback = await setupGoogleDrivePathIntercept( + page, + fileId, + undefined, // we're initiating the navigation below, so no auto-navigate needed + 'test-models/ifc/misc/box.ifc', + ) + }) + + test('Google Drive URL navigates to /share/v/g/ path - Screen', async ({page}) => { await page.getByTestId('control-button-search').click() const searchInput = page.getByPlaceholder(SEARCH_BAR_PLACEHOLDER_TEXT) - await searchInput.fill('https://github.com/bldrs-ai/test-models/main/ifc/misc/box.ifc') + const userInputUrl = `https://drive.google.com/file/d/${fileId}/view` + await searchInput.fill(userInputUrl) await searchInput.press('Enter') + await waitForModelReadyCallback() + await expect(page).toHaveURL(/\/share\/v\/g\//) + await expectScreen(page, 'box-google-drive-link-loaded.png') }) - test('box.ifc loads - Screen', async ({page}) => { - await waitForModelReady(page) - await expectScreen(page, 'box-github-link-loaded.png') + test.afterEach(async ({page}, testInfo) => { + const failed = testInfo.status !== testInfo.expectedStatus // catches fail + unexpected pass + if (failed) { + console.warn(`⏸ Pausing on failure: ${testInfo.title}`) + await page.pause() // keeps browser open; resume/step in Inspector + } }) }) }) diff --git a/src/Components/Search/Search.spec.ts-snapshots/box-github-link-loaded.png b/src/Components/Search/Search.spec.ts-snapshots/box-github-link-loaded.png index f617b94a2..cdff903ff 100644 Binary files a/src/Components/Search/Search.spec.ts-snapshots/box-github-link-loaded.png and b/src/Components/Search/Search.spec.ts-snapshots/box-github-link-loaded.png differ diff --git a/src/Components/Search/Search.spec.ts-snapshots/box-google-drive-link-loaded.png b/src/Components/Search/Search.spec.ts-snapshots/box-google-drive-link-loaded.png new file mode 100644 index 000000000..7fb44724e Binary files /dev/null and b/src/Components/Search/Search.spec.ts-snapshots/box-google-drive-link-loaded.png differ diff --git a/src/Components/Search/Search.spec.ts-snapshots/search-together-highlighted.png b/src/Components/Search/Search.spec.ts-snapshots/search-together-highlighted.png index 58999ed76..1e3829bec 100644 Binary files a/src/Components/Search/Search.spec.ts-snapshots/search-together-highlighted.png and b/src/Components/Search/Search.spec.ts-snapshots/search-together-highlighted.png differ diff --git a/src/Components/Search/Search.spec.ts-snapshots/search-together-permalink.png b/src/Components/Search/Search.spec.ts-snapshots/search-together-permalink.png index f88fa1d2b..3c60bad58 100644 Binary files a/src/Components/Search/Search.spec.ts-snapshots/search-together-permalink.png and b/src/Components/Search/Search.spec.ts-snapshots/search-together-permalink.png differ diff --git a/src/Components/Search/SearchBar.jsx b/src/Components/Search/SearchBar.jsx index 7bac39009..377c741a2 100644 --- a/src/Components/Search/SearchBar.jsx +++ b/src/Components/Search/SearchBar.jsx @@ -2,7 +2,7 @@ import React, {ReactElement, useRef, useEffect, useState} from 'react' import {useLocation, useNavigate, useSearchParams} from 'react-router-dom' import {Autocomplete, TextField} from '@mui/material' import {Close as CloseIcon} from '@mui/icons-material' -import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' +import {githubUrlToSharePath} from '../../routes/github' import {processExternalUrl} from '../../routes/routes' import {disablePageReloadApprovalCheck} from '../../utils/event' import {navWithSearchParamRemoved, navigateToModel} from '../../utils/navigate' @@ -64,33 +64,45 @@ export default function SearchBar({ setError('') } - // if url is typed into the search bar open the model - if (looksLikeLink(inputText)) { + // Check if input starts with http + if (inputText.startsWith('http')) { try { - const modelPath = githubUrlOrPathToSharePath(inputText) - disablePageReloadApprovalCheck() - navigateToModel(modelPath, navigate) - if (onSuccess) { - onSuccess() + // Construct originalUrl from the inputText itself + const originalUrl = new URL(inputText) + const result = processExternalUrl(originalUrl, inputText) + + if (result === null) { + setError('Invalid URL. Needs eg google or github file URL') + return } - } catch (e) { - setError(`Please enter a valid url. Click on the LINK icon to learn more.`) - } - return - } - const result = processExternalUrl(window.location.href, inputText) - if (result) { - try { disablePageReloadApprovalCheck() - navigate(`/share/v/u/${inputText}`) + + if (result.kind === 'provider' && result.provider === 'github') { + // Navigate to GitHub route + const sharePath = githubUrlToSharePath(inputText) + if (sharePath) { + navigateToModel(sharePath, navigate) + } else { + setError('Invalid GitHub URL format') + return + } + } else if (result.kind === 'provider' && result.provider === 'google') { + // Navigate to Google Drive route + navigate(`/share/v/g/${inputText}`) + } else { + // Navigate to generic URL route + navigate(`/share/v/u/${inputText}`) + } + if (onSuccess) { onSuccess() } + return } catch (e) { - setError(`Please enter a valid url.`) + setError('Invalid URL. Needs eg google or github file URL') + return } - return } diff --git a/src/Components/Search/SearchBar.test.jsx b/src/Components/Search/SearchBar.test.jsx index cdad2618b..2a730eb70 100644 --- a/src/Components/Search/SearchBar.test.jsx +++ b/src/Components/Search/SearchBar.test.jsx @@ -1,5 +1,6 @@ import React from 'react' import {render, screen} from '@testing-library/react' +import {MemoryRouter} from 'react-router-dom' // import ShareMock from '../../ShareMock' import {RouteThemeCtx} from '../../Share.fixture' import SearchBar, { @@ -66,3 +67,30 @@ describe( 'SearchBar', () => { expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() }) }) + + +describe('SearchBar URL handling', () => { + it('renders SearchBar component', () => { + render( + + + , + ) + + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + + it('shows error state when error is set', () => { + render( + + + , + ) + + const input = screen.getByPlaceholderText('Search') + + // Simulate error state by checking if the input has error styling + // This is a basic test to ensure the component can handle error states + expect(input).toBeInTheDocument() + }) +}) diff --git a/src/Components/Search/component.ts b/src/Components/Search/component.ts index 94aebf844..185a934df 100644 --- a/src/Components/Search/component.ts +++ b/src/Components/Search/component.ts @@ -1 +1 @@ -export const SEARCH_BAR_PLACEHOLDER_TEXT = 'Seardch or link (Google, GitHub models)' +export const SEARCH_BAR_PLACEHOLDER_TEXT = 'Search or link (Google, GitHub models)' diff --git a/src/Components/Share/ShareableCamera.spec.ts-snapshots/ShareDialog-opens.png b/src/Components/Share/ShareableCamera.spec.ts-snapshots/ShareDialog-opens.png index 8ff2fb76e..4f8582a79 100644 Binary files a/src/Components/Share/ShareableCamera.spec.ts-snapshots/ShareDialog-opens.png and b/src/Components/Share/ShareableCamera.spec.ts-snapshots/ShareDialog-opens.png differ diff --git a/src/Components/Versions/EditModel.spec.ts b/src/Components/Versions/EditModel.spec.ts index 946d625e1..85342e408 100644 --- a/src/Components/Versions/EditModel.spec.ts +++ b/src/Components/Versions/EditModel.spec.ts @@ -1,15 +1,14 @@ -import {expect, test} from '@playwright/test' +import {expect, test, Request, Response} from '@playwright/test' import { auth0Login, homepageSetup, returningUserVisitsHomepageWaitForModel, } from '../../tests/e2e/utils' -import {setupVirtualPathIntercept, waitForModelReady} from '../../tests/e2e/models' import {expectScreen} from '../../tests/screens' +import {setupGithubPathIntercept} from '../../tests/e2e/models' const {beforeEach, describe} = test - /** * Tests for editing a specific version of a model. * Tests saving a model with a new name that overwrites an existing file. @@ -27,28 +26,90 @@ describe('Versions 100: Edit a specific version', () => { beforeEach(async ({page}) => { await returningUserVisitsHomepageWaitForModel(page) await auth0Login(page) - await setupVirtualPathIntercept( - page, - '/share/v/gh/cypresstester/test-repo/main/window.ifc', - '/Momentum.ifc', - ) }) // TODO(https://github.com/bldrs-ai/Share/issues/1269): fix and re-enable // Assumes this flow's file exists cypress/e2e/open/100/open-model-from-gh-ui.cy.js - test.skip('Save index.ifc to new name, that overwrites existing other file', async ({page}) => { + test('Save index.ifc to new name, that overwrites existing other file', async ({page}) => { + // Setup intercept for GitHub API calls to save the model + // Note: MSW intercepts these requests, but Playwright can still see them via request/response listeners + const capturedRequests: Request[] = [] + const capturedResponses: Response[] = [] + const HTTP_OK = 200 + // Match GitHub API calls with proxy prefix: /p/gh/repos/... or /repos/... + const githubApiPattern = /\/repos\/cypresstester\/test-repo\/git\// + + // Listen to all requests (MSW intercepts them, but Playwright still sees them) + page.on('request', (request: Request) => { + if (githubApiPattern.test(request.url())) { + capturedRequests.push(request) + } + }) + + // Listen to all responses (including MSW-handled ones) + page.on('response', (response: Response) => { + if (githubApiPattern.test(response.url())) { + capturedResponses.push(response) + } + }) + + // Wait for the final updateRef call which indicates the save completed + // actual network req: + // https://git.bldrs.dev.msw/p/gh/repos/cypresstester/test-repo/git/refs/heads%2Fmain + const updateRefResponsePromise = page.waitForResponse( + (response: Response) => + response.url().includes('/p/gh/repos/cypresstester/test-repo/git/refs/heads') && + response.request().method() === 'PATCH', + ) + + // setup a + // https://rawgit.bldrs.dev/r/cypresstester/test-repo/main/folder/test.ifc + const ghModelWait = await setupGithubPathIntercept( + page, + '/cypresstester/test-repo/main/folder/test.ifc', + '/share/v/gh/cypresstester/test-repo/main/folder/test.ifc', + 'test-models/ifc/misc/box.ifc', + ) + await expect(page.getByTitle('Save')).toBeVisible({timeout: 5000}) await page.getByTitle('Save').click({force: true}) - await page.getByRole('textbox', {name: 'Organization'}).click() + await page.getByRole('combobox', {name: 'Organization'}).click() await page.getByText('@cypresstester').click() - await page.getByRole('textbox', {name: 'Repository'}).first().click() + await page.getByRole('combobox', {name: 'Repository'}).first().click() await page.getByText('test-repo').click() + await page.getByRole('combobox', {name: 'Branch'}).first().click() + await page.getByText('main').click() + await page.getByRole('combobox', {name: 'Folder'}).first().click() + await page.getByText('folder', {exact: true}).click() await page.getByRole('textbox', {name: 'Enter file name'}).click() await page.getByRole('textbox', {name: 'Enter file name'}).fill('window.ifc') - await page.getByRole('button', {name: 'Save model'}).click() + await page.getByTestId('button-dialog-main-action').click() + + // Wait for the save operation to complete + const updateRefResponse = await updateRefResponsePromise + await ghModelWait() + + // Verify the save API call was made correctly + expect(updateRefResponse.status()).toBe(HTTP_OK) + + // Verify the request body contains the expected file path + const updateRefRequest = capturedRequests.find( + (req) => req.url().includes('/git/refs/heads') && req.method() === 'PATCH', + ) + expect(updateRefRequest).toBeDefined() + + // Verify other key API calls were made + const createBlobRequest = capturedRequests.find( + (req) => req.url().includes('/git/blobs') && req.method() === 'POST', + ) + expect(createBlobRequest).toBeDefined() + + const createCommitRequest = capturedRequests.find( + (req) => req.url().includes('/git/commits') && req.method() === 'POST', + ) + expect(createCommitRequest).toBeDefined() - await waitForModelReady(page) await expectScreen(page, 'EditModel-overwrite-save.png') }) }) diff --git a/src/Components/Versions/EditModel.spec.ts-snapshots/EditModel-overwrite-save.png b/src/Components/Versions/EditModel.spec.ts-snapshots/EditModel-overwrite-save.png new file mode 100644 index 000000000..35c53f486 Binary files /dev/null and b/src/Components/Versions/EditModel.spec.ts-snapshots/EditModel-overwrite-save.png differ diff --git a/src/Components/Versions/ShowVersion.spec.ts b/src/Components/Versions/ShowVersion.spec.ts index 9cdbbc4aa..fe7f1e6db 100644 --- a/src/Components/Versions/ShowVersion.spec.ts +++ b/src/Components/Versions/ShowVersion.spec.ts @@ -4,7 +4,7 @@ import { homepageSetup, returningUserVisitsHomepageWaitForModel, } from '../../tests/e2e/utils' -import {setupVirtualPathIntercept, waitForModelReady} from '../../tests/e2e/models' +import {setupGithubPathIntercept} from '../../tests/e2e/models' import {expectScreen} from '../../tests/screens' @@ -33,74 +33,57 @@ describe('Versions 100: Show a specific version', () => { await auth0Login(page) }) - const percyLabelPrefix = 'Versions 100: Show a specific version,' + const screenLabelPrefix = 'Versions 100: Show a specific version,' // TODO(https://github.com/bldrs-ai/Share/issues/1178) - test.skip('Open Momentum.ifc, open versions component, select three versions', async ({page}) => { + test('Open Momentum.ifc, open versions component, select three versions', async ({page}) => { await page.getByTestId('control-button-open').click() - await page.getByTestId('textfield-sample-projects').click() + const openDialog = page.getByRole('dialog', {name: 'Open'}) + openDialog.getByRole('tab', {name: 'Samples'}).click() + const fixturePathname = 'test-models/ifc/misc/box.ifc' // set up initial momentum.ifc load - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/main/Momentum.ifc', - '/Momentum.ifc', - ) - - // set up versioned momentum.ifc load (testsha commit) - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/testsha1testsha1testsha1testsha1testsha1/Momentum.ifc', - '/Momentum.ifc', - ) - - // set up versioned momentum.ifc load (testsha2 commit) - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/testsha2testsha2testsha2testsha2testsha2/Momentum.ifc', - '/Momentum.ifc', - ) - - // set up versioned momentum.ifc load (testsha3 commit) - await setupVirtualPathIntercept( - page, - '/share/v/gh/Swiss-Property-AG/Momentum-Public/testsha3testsha3testsha3testsha3testsha3/Momentum.ifc', - '/Momentum.ifc', - ) - - await page.getByText('Momentum').click() - await waitForModelReady(page) + let branch = 'main' + let githubPath = `/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + let gotoPath = `/share/v/gh/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + const gotoMainWait = await setupGithubPathIntercept(page, githubPath, gotoPath, fixturePathname) + const momentumChip = openDialog.getByText('Momentum') + await momentumChip.click() + await gotoMainWait() // first commit version test + branch = 'testsha1testsha1testsha1testsha1testsha1' + githubPath = `/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + gotoPath = `/share/v/gh/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + const goto1Wait = await setupGithubPathIntercept(page, githubPath, gotoPath, fixturePathname) await page.getByTestId('control-button-versions').click() const firstTimelineItem = page.getByTestId('timeline-list').locator('.MuiTimelineItem-root').nth(0) await firstTimelineItem.click() - await waitForModelReady(page) - + await goto1Wait() await page.getByTestId('control-button-versions').click() - - const animWaitTimeMs = 1000 - await page.waitForTimeout(animWaitTimeMs) - await expectScreen(page, `${percyLabelPrefix} first commit model visible with matching version selected.png`) + await expectScreen(page, `${screenLabelPrefix} first commit model visible with matching version selected.png`) // second commit version test + branch = 'testsha2testsha2testsha2testsha2testsha2' + githubPath = `/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + gotoPath = `/share/v/gh/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + const goto2Wait = await setupGithubPathIntercept(page, githubPath, gotoPath, fixturePathname) const secondTimelineItem = page.getByTestId('timeline-list').locator('.MuiTimelineItem-root').nth(1) await secondTimelineItem.click() - await waitForModelReady(page) - + await goto2Wait() await page.getByTestId('control-button-versions').click() - - await page.waitForTimeout(animWaitTimeMs) - await expectScreen(page, `${percyLabelPrefix} second commit model visible with matching version selected.png`) + await expectScreen(page, `${screenLabelPrefix} second commit model visible with matching version selected.png`) // third commit version test + branch = 'testsha3testsha3testsha3testsha3testsha3' + githubPath = `/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + gotoPath = `/share/v/gh/Swiss-Property-AG/Momentum-Public/${branch}/Momentum.ifc` + const goto3Wait = await setupGithubPathIntercept(page, githubPath, gotoPath, fixturePathname) const thirdTimelineItem = page.getByTestId('timeline-list').locator('.MuiTimelineItem-root').nth(2) await thirdTimelineItem.click() - await waitForModelReady(page) - + await goto3Wait() await page.getByTestId('control-button-versions').click() - await page.waitForTimeout(animWaitTimeMs) - await expectScreen(page, `${percyLabelPrefix} third commit model visible with matching version selected.png`) + await expectScreen(page, `${screenLabelPrefix} third commit model visible with matching version selected.png`) }) }) }) diff --git a/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-first-commit-model-visible-with-matching-version-selected.png b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-first-commit-model-visible-with-matching-version-selected.png new file mode 100644 index 000000000..7b231b575 Binary files /dev/null and b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-first-commit-model-visible-with-matching-version-selected.png differ diff --git a/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-second-commit-model-visible-with-matching-version-selected.png b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-second-commit-model-visible-with-matching-version-selected.png new file mode 100644 index 000000000..5add0d4f8 Binary files /dev/null and b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-second-commit-model-visible-with-matching-version-selected.png differ diff --git a/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-third-commit-model-visible-with-matching-version-selected.png b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-third-commit-model-visible-with-matching-version-selected.png new file mode 100644 index 000000000..88810c417 Binary files /dev/null and b/src/Components/Versions/ShowVersion.spec.ts-snapshots/Versions-100-Show-a-specific-version-third-commit-model-visible-with-matching-version-selected.png differ diff --git a/src/Containers/Alert.spec.ts b/src/Containers/Alert.spec.ts index 602b4fa08..ea9c59381 100644 --- a/src/Containers/Alert.spec.ts +++ b/src/Containers/Alert.spec.ts @@ -1,4 +1,5 @@ import {expect, Page, test} from '@playwright/test' +import {OutOfMemoryError} from 'src/Alerts' import { homepageSetup, returningUserVisitsHomepageWaitForModel, @@ -6,21 +7,74 @@ import { /** - * Helper to set alert in the Zustand store + * Helper to set alert in the Zustand store, including serializing errors. * * @param page Playwright page object * @param alert Alert value (string, Error, or object) */ -async function setAlert(page: Page, alert: string | Error | {type: string, message: string}) { - await page.evaluate((alertValue: unknown) => { +async function setAlert(page: Page, alert: string | Error | {type: string; message: string}) { + if (alert instanceof Error) { + // serialize in Node/Playwright realm + const errorWithToJSON = alert as Error & {toJSON?: () => Record} + const payload = typeof errorWithToJSON.toJSON === 'function' ? + errorWithToJSON.toJSON() : + {name: alert.name, message: alert.message, stack: alert.stack} + + await page.evaluate((json) => { + const w = window as unknown as WindowWithStore + if (!w.store) { + throw new Error('store not found on window') + } + + // Ensure fromJSON is available in the app bundle for tests + // e.g. export BaseError and keep it on w.Errors.BaseError or similar. + const Errors = (w as unknown as {Errors?: {BaseError?: {fromJSON?: (obj: Record) => Error}}}).Errors ?? {} + + // Prefer the app's factory if present; otherwise fallback. + const err = Errors.BaseError?.fromJSON ? + Errors.BaseError.fromJSON(json) : + Object.assign(new Error(json.message as string), json) + + w.store.getState().setAlert(err) + }, payload) + return + } + + // strings / simple objects are already serializable + await page.evaluate((a) => { + const w = window as unknown as WindowWithStore + if (!w.store) { + throw new Error('store not found on window') + } + + if (typeof a === 'object' && a && 'type' in a) { + const {type, message} = a as {type: string; message: string} + const Errors = (w as unknown as {Errors?: Record Error>}).Errors + const Ctor = (Errors && Errors[type]) || Error + w.store.getState().setAlert(new Ctor(message)) + } else { + w.store.getState().setAlert(a) + } + }, alert) +} + + +/** + * Helper to get alert from the Zustand store + * + * @param page Playwright page object + * @return Alert value (string, Error, or object) + */ +async function getAlert(page: Page) { + return await page.evaluate(() => { if (!(window as unknown as WindowWithStore).store) { throw new Error( 'Zustand store not found on window – make sure win.store is set in test builds.', ) } - (window as unknown as WindowWithStore).store?.getState().setAlert(alertValue) - }, alert) + return (window as unknown as WindowWithStore).store?.getState().alert + }) } @@ -46,7 +100,8 @@ async function setSnackMessage(page: Page, message: string | {text: string, auto type WindowWithStore = Window & { store?: { getState: () => { - setAlert: (alert: unknown) => void + alert: string | Error | {type: string, message: string} | null + setAlert: (alert: string | Error | {type: string, message: string} | null) => void setSnackMessage: (message: unknown) => void } } @@ -66,6 +121,9 @@ describe('Alert and Snackbar', () => { const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByRole('heading', {name: 'Error'})).toBeVisible() + // Description should be set with msg + const alert = await getAlert(page) + expect(alert).toBe('Test error message') await expect(dialog.getByText('Test error message')).toBeVisible() await expect(dialog.getByTestId('button-dialog-main-action')).toHaveText('Reset') }) @@ -87,12 +145,13 @@ describe('Alert and Snackbar', () => { }) test('AlertDialog shows Out of Memory alert with Refresh button', async ({page}) => { - await setAlert(page, {type: 'oom', message: 'Out of memory error'}) + const msg = 'Test out of memory error' + await setAlert(page, new OutOfMemoryError(msg)) const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByRole('heading', {name: 'Out of Memory'})).toBeVisible() - await expect(dialog.getByText('Out of memory error')).toBeVisible() - await expect(dialog.getByTestId('button-dialog-main-action')).toHaveText('Refresh') + await expect(dialog.getByText(msg)).toBeVisible() + await expect(dialog.getByTestId('button-dialog-main-action')).toHaveText('Reset') await expect(page.getByTestId('button-close-dialog-out-of-memory')).toBeVisible() }) diff --git a/src/Containers/AlertDialogAndSnackbar.jsx b/src/Containers/AlertDialogAndSnackbar.jsx index 3bede8d13..bf42856f5 100644 --- a/src/Containers/AlertDialogAndSnackbar.jsx +++ b/src/Containers/AlertDialogAndSnackbar.jsx @@ -1,17 +1,13 @@ import React, {ReactElement, useEffect, useState} from 'react' -import {useNavigate} from 'react-router-dom' import {IconButton, Snackbar, Typography} from '@mui/material' -import {Close as CloseIcon} from '@mui/icons-material' import AlertDialog from '../Components/AlertDialog' import useStore from '../store/useStore' import {assert} from '../utils/assert' -import {navToDefault} from '../utils/navigate' +import {Close as CloseIcon} from '@mui/icons-material' /** @return {ReactElement} */ export default function AlertAndSnackbar() { - const appPrefix = useStore((state) => state.appPrefix) - const snackMessage = useStore((state) => state.snackMessage) const setSnackMessage = useStore((state) => state.setSnackMessage) @@ -19,9 +15,6 @@ export default function AlertAndSnackbar() { const [text, setText] = useState(null) const [duration, setDuration] = useState(null) - const navigate = useNavigate() - - useEffect(() => { if (snackMessage === null) { setIsSnackOpen(false) @@ -48,7 +41,6 @@ export default function AlertAndSnackbar() { { setSnackMessage(null) - navToDefault(navigate, appPrefix) }} /> 0) { + debug(true).warn('CadView#onViewer: viewer already has a model loaded') + return + } + setIsModelReady(false) // define mesh colors for selected and preselected element @@ -186,35 +191,14 @@ export default function CadView({ debug().log('CadView#onViewer: modelPath:', modelPath) let tmpModelRef - let isOOM = false try { tmpModelRef = await loadModel(modelPath) } catch (e) { - if (isOutOfMemoryError(e)) { - isOOM = true - } - if (isOOM) { - // Provide actionable OOM alert object; AlertDialog will render a Refresh button. - setAlert({ - type: 'oom', - message: 'We ran out of memory attempting to load this model. ' + - 'Try opening it on a desktop browser with more memory or ' + - 'refresh the page.', - }) - } else { - setAlert(e) - } - console.error(e) captureException(e) + setAlert(e) return } - if (!tmpModelRef && !isOOM) { - setAlert('Failed to parse model') - return - } - setIsModelLoading(false) - setSnackMessage(null) debug().log('CadView#onViewer: pathToLoad(${pathToLoad}), tmpModelRef: ', tmpModelRef) await onModel(tmpModelRef) @@ -262,6 +246,7 @@ export default function CadView({ * @param {object} routeResult * @param {string} gitpath to use for constructing API endpoints * @return {object} loaded model + * @throws {Error} If model cannot be loaded */ async function loadModel(routeResult) { const filepath = routeResult.downloadUrl || routeResult.filepath @@ -289,23 +274,17 @@ export default function CadView({ } setSnackMessage(`${loadingMessageBase}: ${msg}`) } + let loadedModel try { loadedModel = await load(filepath, viewer, onProgress, (gitpath && gitpath === 'external') ? false : isOpfsAvailable, setOpfsFile, accessToken) - } catch (error) { - if (isOutOfMemoryError(error)) { - error.isOutOfMemory = true - throw error - } - - setAlert(error) - return + // let caller handle the error } finally { setIsModelLoading(false) + setSnackMessage(null) } - // Fix for https://github.com/bldrs-ai/Share/issues/91 // // TODO(pablo): huge hack. Somehow this is getting incremented to diff --git a/src/Containers/CadView.test.jsx b/src/Containers/CadView.test.jsx index f804c2637..9c61b2919 100644 --- a/src/Containers/CadView.test.jsx +++ b/src/Containers/CadView.test.jsx @@ -3,15 +3,16 @@ import React from 'react' import * as reactRouting from 'react-router-dom' import * as Ifc from '@bldrs-ai/ifclib' import {render, renderHook, act, fireEvent, screen, waitFor, within} from '@testing-library/react' -import * as Filetype from '../Filetype' -import ShareMock from '../ShareMock' +import {OutOfMemoryError} from '../Alerts' import {testId as aboutControlTestId} from '../Components/About/AboutControl' -import {HASH_PREFIX_CUT_PLANE} from '../Components/CutPlane/hashState' import {HASH_PREFIX_CAMERA} from '../Components/Camera/hashState' +import {HASH_PREFIX_CUT_PLANE} from '../Components/CutPlane/hashState' +import * as Filetype from '../Filetype' import {IfcViewerAPIExtended} from '../Infrastructure/IfcViewerAPIExtended' +import ShareMock from '../ShareMock' +import * as Loader from '../loader/Loader' import SearchIndex from '../search/SearchIndex' import useStore from '../store/useStore' -import * as Loader from '../loader/Loader' import {makeTestTree} from '../utils/TreeUtils.test' import {actAsyncFlush} from '../utils/tests' import CadView from './CadView' @@ -355,8 +356,7 @@ describe('CadView', () => { it('sets OOM alert object when loader throws out-of-memory error', async () => { // Spy on load (indirectly invoked through loadModel -> load) to throw OOM - const oomErr = new Error('Out of memory: wasm memory allocate failed') - oomErr.isOutOfMemory = true + const oomErr = new OutOfMemoryError() jest.spyOn(Loader, 'load').mockImplementation(() => { throw oomErr }) @@ -372,8 +372,7 @@ describe('CadView', () => { await waitFor(() => { const alert = result.current.alert expect(alert).toBeTruthy() - expect(alert.type).toBe('oom') - expect(alert.message.toLowerCase()).toContain('out of memory') + expect(alert).toBeInstanceOf(OutOfMemoryError) }) expect(consoleErrorSpy).toHaveBeenCalledWith(oomErr) expect(captureExceptionSpy).toHaveBeenCalledWith(oomErr) diff --git a/src/__mocks__/api-handlers-github.ts b/src/__mocks__/api-handlers-github.ts index 4c3915dc6..b06e29b55 100644 --- a/src/__mocks__/api-handlers-github.ts +++ b/src/__mocks__/api-handlers-github.ts @@ -1,7 +1,6 @@ import {http, HttpHandler} from 'msw' import { HTTP_AUTHORIZATION_REQUIRED, - HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, HTTP_NO_CONTENT, @@ -14,14 +13,9 @@ import {MOCK_FILES} from '../net/github/Files.fixture' import {createMockIssues, sampleIssues} from '../net/github/Issues.fixture' import {MOCK_ORGANIZATIONS} from '../net/github/Organizations.fixture' import {MOCK_REPOSITORY, MOCK_USER_REPOSITORIES} from '../net/github/Repositories.fixture' +import {HTTP_OK_JSON, HTTP_BAD_JSON, HTTP_NOT_FOUND_JSON} from './api-handlers' -interface Defines { - GITHUB_BASE_URL: string - GITHUB_BASE_URL_UNAUTHENTICATED: string - RAW_GIT_PROXY_URL?: string -} - let commentDeleted = false @@ -41,10 +35,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht const createdIssues = createMockIssues(org, repo, sampleIssues) return new Response( JSON.stringify(createdIssues), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -61,18 +52,12 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht commentDeleted = false return new Response( JSON.stringify(MOCK_COMMENTS_POST_DELETION.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) } return new Response( JSON.stringify(MOCK_COMMENTS.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -112,10 +97,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht html: 'https://github.com/cypresstester/test-repo/contents/test-model.ifc', }, }), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) } @@ -125,10 +107,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht message: 'Not Found', documentation_url: 'https://docs.github.com/http/reference/repos#get-repository-content', }), - { - status: HTTP_NOT_FOUND, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_NOT_FOUND_JSON, ) } @@ -189,10 +168,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht html: 'https://github.com/bldrs-ai/Share/blob/main/README.md', }, }), - { - status: HTTP_OK, - headers: {'content-type': 'application/json; charset=utf-8'}, - }, + HTTP_OK_JSON, ) }), @@ -204,10 +180,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht JSON.stringify({ message: 'Not Found', }), - { - status: HTTP_NOT_FOUND, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_NOT_FOUND_JSON, ) } @@ -291,20 +264,14 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht return new Response( JSON.stringify(MOCK_ORGANIZATIONS.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), http.get(`${authed ? GH_BASE_AUTHED : GH_BASE_UNAUTHED}/user/repos`, () => { return new Response( JSON.stringify(MOCK_USER_REPOSITORIES.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -313,30 +280,21 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht JSON.stringify({ data: [MOCK_REPOSITORY], }), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), http.get(`${authed ? GH_BASE_AUTHED : GH_BASE_UNAUTHED}/repos/:owner/:repo/contents`, () => { return new Response( JSON.stringify(MOCK_FILES.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), http.get(`${authed ? GH_BASE_AUTHED : GH_BASE_UNAUTHED}/repos/:owner/:repo/branches`, () => { return new Response( JSON.stringify(MOCK_BRANCHES.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -347,19 +305,13 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if (params.owner === 'failurecaseowner' && params.repo === 'failurecaserepo') { return new Response( JSON.stringify({sha: 'error'}), - { - status: HTTP_NOT_FOUND, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_NOT_FOUND_JSON, ) // Handle non existent file request } else if (params.owner === 'nonexistentowner' && params.repo === 'nonexistentrepo') { return new Response( JSON.stringify([]), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) // Handle unauthenticated case } else if (params.owner === 'unauthedcaseowner' && params.repo === 'unauthedcaserepo' ) { @@ -368,18 +320,12 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if ( requestUrl.includes(GH_BASE_AUTHED)) { return new Response( JSON.stringify({sha: 'error'}), - { - status: HTTP_NOT_FOUND, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_NOT_FOUND_JSON, ) } else { return new Response( JSON.stringify(MOCK_COMMITS.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) } // Handle authenticated case @@ -389,28 +335,19 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if ( requestUrl.includes(GH_BASE_UNAUTHED)) { return new Response( JSON.stringify({sha: 'error'}), - { - status: HTTP_NOT_FOUND, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_NOT_FOUND_JSON, ) } else { return new Response( JSON.stringify(MOCK_COMMITS.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) } } // For all other cases, return a success response return new Response( JSON.stringify(MOCK_COMMITS.data), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -423,10 +360,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht http.get(`${authed ? GH_BASE_AUTHED : GH_BASE_UNAUTHED}/repos/:owner/:repo/git/ref/:ref`, () => { return new Response( JSON.stringify({object: {sha: 'parentSha'}}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -434,10 +368,7 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht http.get(`${authed ? GH_BASE_AUTHED : GH_BASE_UNAUTHED}/repos/:owner/:repo/git/commits/:commit_sha`, () => { return new Response( JSON.stringify({tree: {sha: 'treeSha'}}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -447,18 +378,12 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if (content === undefined || encoding === undefined) { return new Response( JSON.stringify({success: false}), - { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_BAD_JSON, ) } return new Response( JSON.stringify({sha: 'blobSha'}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -470,18 +395,12 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if (base_tree === undefined || tree === undefined) { return new Response( JSON.stringify({success: false}), - { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_BAD_JSON, ) } return new Response( JSON.stringify({sha: 'newTreeSha'}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -491,18 +410,12 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if (message === undefined || tree === undefined || parents === undefined) { return new Response( JSON.stringify({success: false}), - { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_BAD_JSON, ) } return new Response( JSON.stringify({sha: 'newCommitSha'}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), @@ -512,19 +425,21 @@ export default function githubApiHandlers(defines: Defines, authed: boolean): Ht if (sha === undefined) { return new Response( JSON.stringify({success: false}), - { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_BAD_JSON, ) } return new Response( JSON.stringify({sha: 'smth'}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, + HTTP_OK_JSON, ) }), ] } + + +// Types +export interface Defines { + GITHUB_BASE_URL: string + GITHUB_BASE_URL_UNAUTHENTICATED: string + RAW_GIT_PROXY_URL?: string +} diff --git a/src/__mocks__/api-handlers-openrouter.ts b/src/__mocks__/api-handlers-openrouter.ts index 36ce6c3ac..d23e1c075 100644 --- a/src/__mocks__/api-handlers-openrouter.ts +++ b/src/__mocks__/api-handlers-openrouter.ts @@ -99,6 +99,7 @@ const OPENROUTER_CORS_HEADERS: Record = { } -interface Defines { +// Types +export interface Defines { OPENROUTER_BASE_URL: string } diff --git a/src/__mocks__/api-handlers.js b/src/__mocks__/api-handlers.ts similarity index 52% rename from src/__mocks__/api-handlers.js rename to src/__mocks__/api-handlers.ts index 0c8dc5e8f..5d07e902d 100644 --- a/src/__mocks__/api-handlers.js +++ b/src/__mocks__/api-handlers.ts @@ -1,37 +1,58 @@ -import {http, passthrough} from 'msw' +import {HttpHandler, http, passthrough} from 'msw' import { HTTP_BAD_REQUEST, + HTTP_NOT_FOUND, HTTP_OK, } from '../net/http' -import apiHandlersGithub from './api-handlers-github' -import apiHandlersOpenrouter from './api-handlers-openrouter' +import apiHandlersGithub, {Defines as GithubDefines} from './api-handlers-github' +import apiHandlersOpenrouter, {Defines as OpenrouterDefines} from './api-handlers-openrouter' /** - * Initialize API handlers, including Google Analytics and GitHub. + * Sandbox dev environement during dev, with some passthru for CI. * - * @param {object} defines - Configuration defines - * @return {Array} handlers + * @param defines - Configuration defines + * @return handlers */ -export function initHandlers(defines) { +export function initHandlers(defines: GithubDefines & OpenrouterDefines): HttpHandler[] { const handlers = [] handlers.push(...prohibitProdAccess()) handlers.push(...workersAndWasmPassthrough()) - handlers.push(...iconAndFontHandlers()) - handlers.push(...apiHandlersGithub(defines, true)) - handlers.push(...apiHandlersGithub(defines, false)) - handlers.push(...netlifyHandlers()) - handlers.push(...subscribePageHandler()) - handlers.push(...stripePortalHandlers()) - handlers.push(...gaHandlers()) - handlers.push(...apiHandlersOpenrouter(defines)) - handlers.push(...googleApisHandlers()) + handlers.push(...iconsFontsCssHandlers()) + // Pass through paths that are served by static assets or playwright fixtures handlers.push(http.get('/share/v/p/*', () => passthrough())) + + // AI + handlers.push(...apiHandlersOpenrouter(defines)) + + // GitHub handlers.push(http.get('/share/v/gh/*', () => passthrough())) + handlers.push(...apiHandlersGithub(defines, true)) + handlers.push(...apiHandlersGithub(defines, false)) handlers.push(http.get('https://rawgit.bldrs.dev/model/*', () => passthrough())) handlers.push(http.get('https://rawgit.bldrs.dev/r/*', () => passthrough())) + + // Google Drive + handlers.push(http.get('/share/v/g/*', () => passthrough())) + handlers.push(...googleApisHandlers()) + handlers.push(http.get('https://www.googleapis.com/drive/v3/files/*', () => passthrough())) + + // Generic URL, especially for local static server + handlers.push(http.get('/share/v/u/*', () => passthrough())) + handlers.push(http.get(/^https:\/\/localhost:\d+\//, () => passthrough())) // local static server + + // Analytics + handlers.push(...gaHandlers()) + + // Stripe + handlers.push(...netlifyHandlers()) + handlers.push(...subscribePageHandler()) + handlers.push(...stripePortalHandlers()) + + // Esbuild hot-reload handlers.push(...installEsbuildHotReloadHandler()) + return handlers } @@ -39,16 +60,13 @@ export function initHandlers(defines) { /** * Detect and error on absolute refs to prod. * - * @return {Array} handlers + * @return handlers */ -function prohibitProdAccess() { +function prohibitProdAccess(): HttpHandler[] { return [ http.get('http://bldrs.ai/*', ({request}) => { console.error('Found absolute ref to prod:', request.url) - return new Response('', { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'text/plain'}, - }) + return new Response('', HTTP_BAD_JSON) }), ] } @@ -57,25 +75,19 @@ function prohibitProdAccess() { /** * Passthru for expected icons and fonts, null route prod static icon requests. * - * @return {Array} handlers + * @return handlers */ -function iconAndFontHandlers() { +function iconsFontsCssHandlers(): HttpHandler[] { return [ + // CSS + http.get(/\/index\.css$/, () => passthrough()), // Icons http.get(/\/favicon\.ico$/, () => passthrough()), http.get(/\/icons/, () => passthrough()), + // Fonts http.get(/\/roboto-*/, () => passthrough()), http.get('http://bldrs.ai/icons/*', () => { - return new Response('', { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'text/plain'}, - }) - }), - http.get(/\/favicon\.ico$/, () => { - return new Response('', { - status: HTTP_OK, - headers: {'Content-Type': 'image/x-icon'}, - }) + return new Response('', HTTP_BAD_JSON) }), ] } @@ -84,9 +96,9 @@ function iconAndFontHandlers() { /** * Let requests for web workers, wasm and related files to passthrough. * - * @return {Array} handlers + * @return handlers */ -function workersAndWasmPassthrough() { +function workersAndWasmPassthrough(): HttpHandler[] { return [ // Caching + OPFS http.get(/\/Cache\.js$/, () => passthrough()), @@ -101,32 +113,20 @@ function workersAndWasmPassthrough() { /** * Handlers for Netlify functions * - * @return {Array} handlers + * @return handlers */ -function netlifyHandlers() { +function netlifyHandlers(): HttpHandler[] { return [ http.post('/.netlify/functions/create-portal-session', async ({request}) => { - const {stripeCustomerId} = await request.json() + const {stripeCustomerId} = await request.json() as {stripeCustomerId?: string} if (!stripeCustomerId) { - return new Response( - JSON.stringify({error: 'Missing stripeCustomerId'}), - { - status: HTTP_BAD_REQUEST, - headers: {'Content-Type': 'application/json'}, - }, - ) + return new Response(JSON.stringify({error: 'Missing stripeCustomerId'}), HTTP_BAD_JSON) } // return a mocked Stripe billing-portal URL const fakeUrl = `https://stripe.portal.msw/mockportal/session/${stripeCustomerId}` - return new Response( - JSON.stringify({url: fakeUrl}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, - ) + return new Response(JSON.stringify({url: fakeUrl}), HTTP_OK_JSON) }), ] } @@ -134,14 +134,14 @@ function netlifyHandlers() { /** * Mock out the “/subscribe” page itself. * - * @return {Array} handlers + * @return handlers */ -function subscribePageHandler() { +function subscribePageHandler(): HttpHandler[] { return [ // this will catch GET /subscribe, /subscribe/, or /subscribe?foo=bar http.get('/subscribe*', () => { - return new Response(` - + return new Response( + ` @@ -153,10 +153,8 @@ function subscribePageHandler() { - `.trim(), { - status: HTTP_OK, - headers: {'Content-Type': 'text/html'}, - }) + `.trim(), + HTTP_OK_JSON) }), ] } @@ -165,15 +163,12 @@ function subscribePageHandler() { /** * Catch the client navigating to the fake Stripe portal page. * - * @return {Array} handlers + * @return handlers */ -function stripePortalHandlers() { +function stripePortalHandlers(): HttpHandler[] { return [ http.get('https://stripe.portal.msw/mockportal/session/:stripeCustomerId', () => { - return new Response('

Mock Stripe Portal

', { - status: HTTP_OK, - headers: {'Content-Type': 'text/html'}, - }) + return new Response('

Mock Stripe Portal

', HTTP_OK_JSON) }), ] } @@ -182,35 +177,15 @@ function stripePortalHandlers() { /** * Mock to disable Google Analytics. * - * @return {Array} handlers + * @return handlers */ -function gaHandlers() { +function gaHandlers(): HttpHandler[] { + const gaUrl = 'https://*.google-analytics.com/*' + const gtmUrl = 'https://*.googletagmanager.com/*' return [ - http.get('https://*.google-analytics.com/*', () => { - return new Response( - JSON.stringify({}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, - ) - }), - - http.post('https://*.google-analytics.com/*', () => { - return new Response(null, { - status: HTTP_OK, - }) - }), - - http.get('https://*.googletagmanager.com/*', () => { - return new Response( - JSON.stringify({}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, - ) - }), + http.get(gaUrl, () => GET_RSP_OK_JSON), + http.post(gaUrl, () => POST_RSP_OK_NULL), + http.get(gtmUrl, () => GET_RSP_OK_JSON), ] } @@ -218,24 +193,13 @@ function gaHandlers() { /** * Google APIs handlers * - * @return {Array} handlers + * @return handlers */ -function googleApisHandlers() { +function googleApisHandlers(): HttpHandler[] { + const gaUrl = 'https://*.googleapis.com/*' return [ - http.get('https://*.googleapis.com/*', () => { - return new Response( - JSON.stringify({}), - { - status: HTTP_OK, - headers: {'Content-Type': 'application/json'}, - }, - ) - }), - http.post('https://*.googleapis.com/*', () => { - return new Response(null, { - status: HTTP_OK, - }) - }), + http.get(gaUrl, () => GET_RSP_OK_JSON), + http.post(gaUrl, () => POST_RSP_OK_NULL), ] } @@ -243,9 +207,9 @@ function googleApisHandlers() { /** * Passthru for esbuild hot-reload plugin * - * @return {Array} handlers + * @return handlers */ -function installEsbuildHotReloadHandler() { +function installEsbuildHotReloadHandler(): HttpHandler[] { const ESBUILD_WATCH = (typeof process !== 'undefined' && process.env?.ESBUILD_WATCH) if (ESBUILD_WATCH) { return [ @@ -256,3 +220,12 @@ function installEsbuildHotReloadHandler() { return [] } } + + +// Helpers +const JSON_CONTENT_TYPE = {'Content-Type': 'application/json'} +export const HTTP_OK_JSON = {status: HTTP_OK, headers: JSON_CONTENT_TYPE} +export const HTTP_BAD_JSON = {status: HTTP_BAD_REQUEST, headers: JSON_CONTENT_TYPE} +export const HTTP_NOT_FOUND_JSON = {status: HTTP_NOT_FOUND, headers: JSON_CONTENT_TYPE} +export const GET_RSP_OK_JSON = new Response(JSON.stringify({}), HTTP_OK_JSON) +export const POST_RSP_OK_NULL = new Response(null, HTTP_OK_JSON) diff --git a/src/loader/Alerts.js b/src/loader/Alerts.js new file mode 100644 index 000000000..2e993ea40 --- /dev/null +++ b/src/loader/Alerts.js @@ -0,0 +1,34 @@ +import {Exception, Alert} from '../Alerts' + + +/** + * Error when the cache is invalid. + * + * @param {string} message + */ +export class CacheException extends Exception { + /** @param {string} message */ + constructor(message) { + super(message) + this.name = 'CacheException' + this.title = message || 'Cache exception' + this.description = `${message || 'Cached exception'}. Please clear your cache and try again. ` + + 'See our [Troubleshooting](http://github.com/bldrs-ai/Share/wiki/Troubleshooting#cache-corruption) ' + + 'page for more information.\n' + + '- Try: **Profile > Clear Local Cache**' + this.action = 'Clear cache' + this.actionUrl = '/' // hard reset the app + } +} + + +/** For network or file resources that are not found. */ +export class NotFoundAlert extends Alert { + /** @param {string} message */ + constructor(message) { + super(message) + this.name = 'NotFoundAlert' + this.title = 'File not found' + this.description = 'The file you are trying to access does not exist.' + } +} diff --git a/src/loader/Loader.js b/src/loader/Loader.js index 5e5c484b9..bf613e51d 100644 --- a/src/loader/Loader.js +++ b/src/loader/Loader.js @@ -15,14 +15,14 @@ import {enablePageReloadApprovalCheck} from '../utils/event' import debug from '../utils/debug' import {parseGitHubPath} from '../utils/location' import {testUuid} from '../utils/strings' -import {dereferenceAndProxyDownloadContents} from './urls' import BLDLoader from './BLDLoader' +import {CacheException, NotFoundAlert} from './Alerts' +import {dereferenceAndProxyDownloadContents} from './urls' import glbToThree from './glb' import objToThree from './obj' import pdbToThree from './pdb' import stlToThree from './stl' import xyzToThree from './xyz' -import {isOutOfMemoryError} from '../utils/oom' /** @@ -95,24 +95,17 @@ export async function load( throw new Error(`Unknown filetype for: ${path}`) } + onProgress('Downloading model...') let modelData if (isOpfsAvailable) { - onProgress('Preparing file download...') // download to file using caching system or else... - let file + let modelFile if (isUploadedFile) { debug().log('Loader#load: getModelFromOPFS for upload:', path) - file = await getModelFromOPFS('BldrsLocalStorage', 'V1', 'Projects', path) + modelFile = await getModelFromOPFS('BldrsLocalStorage', 'V1', 'Projects', path) } else if (isLocallyHostedFile) { debug().log('Loader#load: local file:', path) - file = await downloadToOPFS( - path, - path, - 'bldrs-ai', - 'BldrsLocalStorage', - 'V1', - 'Projects', - onProgress) + modelFile = await downloadToOPFS(path, path, 'bldrs-ai', 'BldrsLocalStorage', 'V1', 'Projects', onProgress) } else { let pathUrl try { @@ -130,10 +123,10 @@ export async function load( } if (isBase64) { - file = await writeBase64Model(derefPath, shaHash, filePath, accessToken, owner, repo, branch, setOpfsFile) + modelFile = await writeBase64Model(derefPath, shaHash, filePath, accessToken, owner, repo, branch, setOpfsFile) } else { debug().log(`Loader#load: downloadModel with owner, repo, branch, filePath:`, owner, repo, branch, filePath) - file = await downloadModel( + modelFile = await downloadModel( derefPath, shaHash, filePath, @@ -147,7 +140,7 @@ export async function load( } else { const opfsFilename = pathUrl.pathname debug().log(`Loader#load: downloadToOPFS with opfsFilename:`, opfsFilename) - file = await downloadToOPFS( + modelFile = await downloadToOPFS( path, opfsFilename, pathUrl.host, @@ -157,10 +150,13 @@ export async function load( onProgress) } } - debug().log('Loader#load: File from OPFS:', file) - setOpfsFile(file) onProgress('Reading model data...') - modelData = await file.arrayBuffer() + debug().log('Loader#load: Model from OPFS:', modelFile) + setOpfsFile(modelFile) + if (modelFile.size === 0) { + throw new CacheException('Empty model') + } + modelData = await modelFile.arrayBuffer() if (isFormatText) { onProgress('Decoding model data...') const decoder = new TextDecoder('utf-8') @@ -168,7 +164,6 @@ export async function load( debug().log('Loader#load: modelData from OPFS (decoded):', modelData) } } else { - onProgress('Downloading model data...') modelData = await axiosDownload(derefPath, isFormatText, onProgress) debug().log('Loader#load: modelData from axios download:', modelData) } @@ -177,22 +172,12 @@ export async function load( // correct resolution of subpaths with '../'. const basePath = path.substring(0, path.lastIndexOf('/') + 1) - let model - try { - model = await readModel(loader, modelData, basePath, isLoaderAsync, isIfc, viewer, fixupCb, onProgress) - } catch (e) { - if (isOutOfMemoryError(e)) { - e.isOutOfMemory = true - } - throw e - } + // Throws + const model = await readModel(loader, modelData, basePath, isLoaderAsync, isIfc, viewer, fixupCb, onProgress) if (model === null || model === undefined) { // If loader captured a last error, surface that const lastErr = (viewer && viewer.IFC && viewer.IFC.ifcLastError) || new Error('Failed to parse IFC model') - if (isOutOfMemoryError(lastErr)) { - lastErr.isOutOfMemory = true - } throw lastErr } @@ -254,7 +239,7 @@ async function axiosDownload(path, isFormatText, onProgress) { if (error.response) { console.warn('error.response.status:', error.response.status) if (error.response.status === HTTP_NOT_FOUND) { - throw new NotFoundError('File not found') + throw new NotFoundAlert('File not found on server') } else { throw new Error(`Error response from server: status(${error.response.status}), message(${error.response.data})`) } @@ -576,71 +561,58 @@ function newIfcLoader(viewer) { onError, ) { if (this.context.items.ifcModels.length !== 0) { - throw new Error('Model cannot be loaded. A model is already present') + throw new Error('Attempt to load a model into an already-used viewer instance') } - try { - if (onProgress) { - onProgress('Configuring loader...') - } - await this.loader.ifcManager.applyWebIfcConfig({ - COORDINATE_TO_ORIGIN: true, - USE_FAST_BOOLS: true, - }) - if (onProgress) { - onProgress('Parsing model geometry...') - } - const ifcModel = await this.loader.parse(buffer, onProgress) - this.addIfcModel(ifcModel) + if (onProgress) { + onProgress('Configuring loader...') + } + await this.loader.ifcManager.applyWebIfcConfig({ + COORDINATE_TO_ORIGIN: true, + USE_FAST_BOOLS: true, + }) - if (onProgress) { - onProgress('Setting up coordinate system...') - } - // eslint-disable-next-line new-cap - const matrixArr = await this.loader.ifcManager.ifcAPI.GetCoordinationMatrix(ifcModel.modelID) - const matrix = new Matrix4().fromArray(matrixArr) - this.loader.ifcManager.setupCoordinationMatrix(matrix) + if (onProgress) { + onProgress('Parsing model geometry...') + } + const ifcModel = await this.loader.parse(buffer, onProgress) + this.addIfcModel(ifcModel) - if (onProgress) { - onProgress('Fitting model to frame...') - } - this.context.fitToFrame() + if (onProgress) { + onProgress('Setting up coordinate system...') + } + // eslint-disable-next-line new-cap + const matrixArr = await this.loader.ifcManager.ifcAPI.GetCoordinationMatrix(ifcModel.modelID) + const matrix = new Matrix4().fromArray(matrixArr) + this.loader.ifcManager.setupCoordinationMatrix(matrix) - if (onProgress) { - onProgress('Gathering model statistics...') - } - const statsApi = this.loader.ifcManager.ifcAPI.getStatistics(0) - ifcModel.name = statsApi.projectName ?? undefined - const loadStats = { - loaderVersion: this.loader.ifcManager.ifcAPI.getConwayVersion(), - geometryMemory: statsApi.getGeometryMemory(), - geometryTime: statsApi.getGeometryTime(), - ifcVersion: statsApi.getVersion(), - loadStatus: statsApi.getLoadStatus(), - originatingSystem: statsApi.getOriginatingSystem(), - preprocessorVersion: statsApi.getPreprocessorVersion(), - parseTime: statsApi.getParseTime(), - totalTime: statsApi.getTotalTime(), - } - ifcModel.loadStats = loadStats + if (onProgress) { + onProgress('Fitting model to frame...') + } + this.context.fitToFrame() - if (onProgress) { - onProgress('Model loaded successfully!') - } - return ifcModel - } catch (err) { - loader.ifcLastError = err - // Rethrow OOM so callers can present a tailored UX message. - if (isOutOfMemoryError(err)) { - err.isOutOfMemory = true // tag for convenience - throw err - } - console.error(err) - if (onError) { - onError(err) - } - return null + if (onProgress) { + onProgress('Gathering model statistics...') + } + const statsApi = this.loader.ifcManager.ifcAPI.getStatistics(0) + ifcModel.name = statsApi.projectName ?? undefined + const loadStats = { + loaderVersion: this.loader.ifcManager.ifcAPI.getConwayVersion(), + geometryMemory: statsApi.getGeometryMemory(), + geometryTime: statsApi.getGeometryTime(), + ifcVersion: statsApi.getVersion(), + loadStatus: statsApi.getLoadStatus(), + originatingSystem: statsApi.getOriginatingSystem(), + preprocessorVersion: statsApi.getPreprocessorVersion(), + parseTime: statsApi.getParseTime(), + totalTime: statsApi.getTotalTime(), + } + ifcModel.loadStats = loadStats + + if (onProgress) { + onProgress('Model loaded successfully!') } + return ifcModel } return loader } @@ -661,16 +633,3 @@ function onDownloadProgressHandler(progressEvent, onProgress) { onProgress(`${loadedMegs} MB`) } } - - -/** For network or file resources that are not found. */ -export class NotFoundError extends Error { - /** @param {string} message */ - constructor(message) { - super(message) - this.name = 'NotFoundError' - if (Error.captureStackTrace) { - Error.captureStackTrace(this, NotFoundError) // Captures stack trace, excluding constructor call - } - } -} diff --git a/src/loader/Loader.test.js b/src/loader/Loader.test.js index ddc7b0a25..550890d93 100644 --- a/src/loader/Loader.test.js +++ b/src/loader/Loader.test.js @@ -320,7 +320,7 @@ describe('Loader', () => { // Verify progress messages are called in correct order const progressCalls = onProgress.mock.calls.map((call) => call[0]) expect(progressCalls).toContain('Determining file type...') - expect(progressCalls).toContain('Preparing file download...') + expect(progressCalls).toContain('Downloading model...') expect(progressCalls).toContain('Reading model data...') expect(progressCalls).toContain('Configuring loader...') expect(progressCalls).toContain('Parsing model geometry...') @@ -349,7 +349,7 @@ describe('Loader', () => { const progressCalls = onProgress.mock.calls.map((call) => call[0]) expect(progressCalls).toContain('Determining file type...') - expect(progressCalls).toContain('Preparing file download...') + expect(progressCalls).toContain('Downloading model...') expect(progressCalls).toContain('Reading model data...') expect(progressCalls).toContain('Configuring loader...') expect(progressCalls).toContain('Parsing model geometry...') @@ -376,7 +376,7 @@ describe('Loader', () => { const progressCalls = onProgress.mock.calls.map((call) => call[0]) expect(progressCalls).toContain('Determining file type...') - expect(progressCalls).toContain('Preparing file download...') + expect(progressCalls).toContain('Downloading model...') expect(progressCalls).toContain('Reading model data...') expect(progressCalls).toContain('Decoding model data...') expect(progressCalls).toContain('Processing model data...') @@ -400,7 +400,7 @@ describe('Loader', () => { const progressCalls = onProgress.mock.calls.map((call) => call[0]) expect(progressCalls).toContain('Determining file type...') - expect(progressCalls).toContain('Preparing file download...') + expect(progressCalls).toContain('Downloading model...') expect(progressCalls).toContain('Reading model data...') // Should NOT contain 'Decoding model data...' for binary files expect(progressCalls).not.toContain('Decoding model data...') diff --git a/src/loader/README.md b/src/loader/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/net/Alerts.ts b/src/net/Alerts.ts new file mode 100644 index 000000000..e00a2cf82 --- /dev/null +++ b/src/net/Alerts.ts @@ -0,0 +1,29 @@ +import {Exception, Alert} from '../Alerts' + + +/** + * Simple network exception for major network problems that + * the app should try to recover from. + */ +export class NetworkException extends Exception { + /** @param message Error message */ + constructor(message: string) { + super(message) + this.title = 'Network exception' + this.description = 'A problem with the network resource occurred.' + } +} + + +/** + * Simple HTTP alert for HTTP errors that the app may want to + * display to the user. + */ +export class HttpAlert extends Alert { + /** @param message Error message */ + constructor(message: string) { + super(message) + this.title = 'HTTP alert' + this.description = 'A problem with the web occurred.' + } +} diff --git a/src/net/github/utils.js b/src/net/github/utils.js index 2de12f23d..60896a5cf 100644 --- a/src/net/github/utils.js +++ b/src/net/github/utils.js @@ -28,10 +28,10 @@ export const parseGitHubRepositoryUrl = (githubUrl) => { throw new Error('Not a valid GitHub repository URL') } const match = url.pathname.match(`^/${pathParts.join('/')}$`) - if (match === null) { + if (match === null || !match.groups) { throw new Error('Could not match GitHub repository URL') } - const {groups: {org, repo, branch, file}} = match + const {org, repo, branch, file} = match.groups return { url: url, owner: org, @@ -62,8 +62,8 @@ const re = new RegExp(`^/${pathParts.join('/')}$`) */ export function extractOrgPrefixedPath(urlWithPath) { const match = re.exec(urlWithPath) // TODO actually handle - if (match) { - const {groups: {org, repo, branch, file}} = match + if (match && match.groups) { + const {org, repo, branch, file} = match.groups return `/${org}/${repo}/${branch}/${file}` } throw new Error(`Expected a multi-part file path: ${urlWithPath}`) @@ -84,12 +84,12 @@ export function githubUrlOrPathToSharePath(urlWithPath) { /** * Check if input is a url * - * @param {input} input + * @param {string} input * @return {boolean} return true if url is found */ export function looksLikeLink(input) { assertDefined(input) - return pathSuffixSupported(input) && ( + return typeof input === 'string' && pathSuffixSupported(input) && ( input.startsWith('http') || input.startsWith('/') || input.startsWith('bldrs') || @@ -102,14 +102,17 @@ export function looksLikeLink(input) { /** * Look for any obvious problems with the given url. * - * @param {object} urlStr - * @return {boolean} return true if url is found + * @param {string} urlStr + * @return {string} The trimmed path * @throws Error if the argument have path slash '/' characters after * trimming host and appinstal prefix. * @private */ export function trimToPath(urlStr) { assertDefined(urlStr) + if (typeof urlStr !== 'string') { + throw new Error('urlStr must be a string') + } let s = urlStr.trim() if (s.startsWith('http://')) { s = s.substring('http://'.length) diff --git a/src/routes/github.test.ts b/src/routes/github.test.ts index 7a157f7f2..f9555b84e 100644 --- a/src/routes/github.test.ts +++ b/src/routes/github.test.ts @@ -1,4 +1,4 @@ -import processGitHubFile from './github' +import processGitHubFile, {processGithubUrl, githubUrlToSharePath} from './github' describe('processGitHubFile', () => { @@ -86,3 +86,108 @@ describe('processGitHubFile', () => { }) }) }) + + +describe('processGithubUrl', () => { + const originalUrl = new URL('http://bldrs.ai/share/v/gh/test-org/test-repo/main/model.ifc') + + it('processes valid GitHub URL with blob path', () => { + const githubUrl = new URL('https://github.com/test-org/test-repo/blob/main/path/to/model.ifc') + const result = processGithubUrl(originalUrl, githubUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL('https://github.com/test-org/test-repo/main/path/to/model.ifc'), + kind: 'provider', + provider: 'github', + org: 'test-org', + repo: 'test-repo', + branch: 'main', + filepath: 'path/to/model.ifc', + getRepoPath: expect.any(Function), + gitpath: 'https://github.com/test-org/test-repo/main/path/to/model.ifc', + }) + }) + + it('processes valid GitHub URL with raw path', () => { + const githubUrl = new URL('https://raw.githubusercontent.com/test-org/test-repo/main/path/to/model.ifc') + const result = processGithubUrl(originalUrl, githubUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL('https://github.com/test-org/test-repo/main/path/to/model.ifc'), + kind: 'provider', + provider: 'github', + org: 'test-org', + repo: 'test-repo', + branch: 'main', + filepath: 'path/to/model.ifc', + getRepoPath: expect.any(Function), + gitpath: 'https://github.com/test-org/test-repo/main/path/to/model.ifc', + }) + }) + + it('processes GitHub URL with element path', () => { + const githubUrl = new URL('https://github.com/test-org/test-repo/blob/main/path/to/model.ifc/1/2/3') + const result = processGithubUrl(originalUrl, githubUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL('https://github.com/test-org/test-repo/main/path/to/model.ifc'), + kind: 'provider', + provider: 'github', + org: 'test-org', + repo: 'test-repo', + branch: 'main', + filepath: 'path/to/model.ifc', + eltPath: '1/2/3', + getRepoPath: expect.any(Function), + gitpath: 'https://github.com/test-org/test-repo/main/path/to/model.ifc', + }) + }) + + it('returns null for non-GitHub URL', () => { + const nonGithubUrl = new URL('https://drive.google.com/file/d/123/view') + const result = processGithubUrl(originalUrl, nonGithubUrl) + + expect(result).toBeNull() + }) + + it('returns null for invalid GitHub URL format', () => { + const invalidGithubUrl = new URL('https://github.com/invalid-format') + const result = processGithubUrl(originalUrl, invalidGithubUrl) + + expect(result).toBeNull() + }) +}) + + +describe('githubUrlToSharePath', () => { + it('converts valid GitHub URL to share path', () => { + const githubUrl = 'https://github.com/test-org/test-repo/blob/main/path/to/model.ifc' + const result = githubUrlToSharePath(githubUrl) + + expect(result).toBe('/share/v/gh/test-org/test-repo/main/path/to/model.ifc') + }) + + it('converts raw GitHub URL to share path', () => { + const githubUrl = 'https://raw.githubusercontent.com/test-org/test-repo/main/path/to/model.ifc' + const result = githubUrlToSharePath(githubUrl) + + expect(result).toBe('/share/v/gh/test-org/test-repo/main/path/to/model.ifc') + }) + + it('returns null for non-GitHub URL', () => { + const nonGithubUrl = 'https://drive.google.com/file/d/123/view' + const result = githubUrlToSharePath(nonGithubUrl) + + expect(result).toBeNull() + }) + + it('returns null for invalid GitHub URL format', () => { + const invalidGithubUrl = 'https://github.com/invalid-format' + const result = githubUrlToSharePath(invalidGithubUrl) + + expect(result).toBeNull() + }) +}) diff --git a/src/routes/github.ts b/src/routes/github.ts index b6cd8f59c..83f41f080 100644 --- a/src/routes/github.ts +++ b/src/routes/github.ts @@ -1,4 +1,5 @@ import {splitAroundExtensionRemoveFirstSlash} from '../Filetype' +import {parseGitHubRepositoryUrl} from '../net/github/utils' import type {ProviderResult, BaseParams} from './routes' @@ -60,3 +61,58 @@ export interface GithubResult extends ProviderResult { getRepoPath(): string gitpath: string } + + +/** + * Processes a GitHub URL and returns the result. + * + * @param originalUrl - The original URL + * @param maybeGithubUrl - The GitHub URL to process + * @return Result or null + */ +export function processGithubUrl(originalUrl: URL, maybeGithubUrl: URL): GithubResult | null { + try { + const parsed = parseGitHubRepositoryUrl(maybeGithubUrl.toString()) + const {owner, repository, ref, path} = parsed as {owner: string, repository: string, ref: string, path: string} + + const {parts, extension} = splitAroundExtensionRemoveFirstSlash(path) + const reducedFilePath = `${parts[0]}${extension}` + const getRepoPath = () => `/${owner}/${repository}/${ref}/${reducedFilePath}` + const downloadUrl = new URL(`https://github.com${getRepoPath()}`) + + const result: GithubResult = { + originalUrl, + downloadUrl, + kind: 'provider', + provider: 'github', + org: owner, + repo: repository, + branch: ref, + filepath: reducedFilePath, + getRepoPath, + gitpath: downloadUrl.toString(), + ...(parts[1] ? {eltPath: parts[1]} : {}), + } + + return result + } catch { + return null + } +} + + +/** + * Converts GitHub URL info to a share path. + * + * @param githubUrl - The GitHub URL string + * @return Share path or null if not a valid GitHub URL + */ +export function githubUrlToSharePath(githubUrl: string): string | null { + try { + const parsed = parseGitHubRepositoryUrl(githubUrl) + const {owner, repository, ref, path} = parsed as {owner: string, repository: string, ref: string, path: string} + return `/share/v/gh/${owner}/${repository}/${ref}/${path}` + } catch { + return null + } +} diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index ee314a406..87ab631b1 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -1,6 +1,6 @@ import {GithubResult} from './github' import {GoogleResult} from './google' -import {handleRoute, type RouteParams, type FileResult} from './routes' +import {handleRoute, processExternalUrl, type RouteParams, type FileResult} from './routes' // Test one of each kind of route.There's more detailed tests in the github and google tests. @@ -163,3 +163,70 @@ describe('routes', () => { }) }) }) + + +describe('processExternalUrl', () => { + const originalUrl = new URL('http://bldrs.ai/share/v/u/test') + + it('processes GitHub URL and returns GithubResult', () => { + const githubUrl = 'https://github.com/test-org/test-repo/blob/main/path/to/model.ifc' + const result = processExternalUrl(originalUrl, githubUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL('https://github.com/test-org/test-repo/main/path/to/model.ifc'), + kind: 'provider', + provider: 'github', + org: 'test-org', + repo: 'test-repo', + branch: 'main', + filepath: 'path/to/model.ifc', + getRepoPath: expect.any(Function), + gitpath: 'https://github.com/test-org/test-repo/main/path/to/model.ifc', + }) + }) + + it('processes Google Drive URL and returns GoogleResult', () => { + const googleUrl = 'https://drive.google.com/file/d/1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO/view' + const result = processExternalUrl(originalUrl, googleUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL( + `https://www.googleapis.com/drive/v3/files/1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO?alt=media&key=${process.env.GOOGLE_API_KEY}`, + ), + kind: 'provider', + provider: 'google', + fileId: '1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO', + }) + }) + + it('processes generic URL and returns UrlResult', () => { + const genericUrl = 'https://example.com/file.ifc' + const result = processExternalUrl(originalUrl, genericUrl) + + expect(result).toEqual({ + originalUrl, + downloadUrl: new URL(genericUrl), + kind: 'url', + }) + }) + + it('returns null for invalid URL', () => { + const invalidUrl = 'not-a-url' + const result = processExternalUrl(originalUrl, invalidUrl) + + expect(result).toBeNull() + }) + + it('prioritizes GitHub over Google Drive when both could match', () => { + // This test ensures GitHub detection happens first + const githubUrl = 'https://github.com/test-org/test-repo/blob/main/path/to/model.ifc' + const result = processExternalUrl(originalUrl, githubUrl) + + expect(result?.kind).toBe('provider') + if (result?.kind === 'provider') { + expect(result.provider).toBe('github') + } + }) +}) diff --git a/src/routes/routes.ts b/src/routes/routes.ts index f73866400..fc79a8614 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,6 +1,6 @@ import {splitAroundExtensionRemoveFirstSlash} from '../Filetype' import debug from '../utils/debug' -import processGithubParams, {isGithubParams, GithubParams, GithubResult} from './github' +import processGithubParams, {isGithubParams, GithubParams, GithubResult, processGithubUrl} from './github' import processGoogleUrl, {GoogleResult, processGoogleFileId} from './google' @@ -95,23 +95,33 @@ export function processFile(originalUrl: URL, filepath: string): FileResult { /** - * Processes a URL filepath for external content, currently supporting Google Drive. + * Processes a URL filepath for external content, supporting GitHub and Google Drive. * * @param originalUrl - The original URL * @param maybeUrlParamStr - The embedded file path to process, should be a URL - * @return The original URL, a Google File ID or null. + * @return The original URL, a GitHub/Google File ID or null. */ -export function processExternalUrl(originalUrl: URL, maybeUrlParamStr: string): GoogleResult | UrlResult | null { +export function processExternalUrl(originalUrl: URL, maybeUrlParamStr: string): GithubResult | GoogleResult | UrlResult | null { let urlParam: URL try { urlParam = new URL(maybeUrlParamStr) } catch { return null } - const result: GoogleResult = processGoogleUrl(originalUrl, urlParam) as GoogleResult - if (result) { - return result + + // Try GitHub URL first + const githubResult: GithubResult | null = processGithubUrl(originalUrl, urlParam) + if (githubResult) { + return githubResult + } + + // Try Google Drive URL + const googleResult: GoogleResult | null = processGoogleUrl(originalUrl, urlParam) as GoogleResult + if (googleResult) { + return googleResult } + + // Fallback to generic URL return { originalUrl, downloadUrl: urlParam, diff --git a/src/tests/e2e/models.ts b/src/tests/e2e/models.ts index e1b51cbce..82909396f 100644 --- a/src/tests/e2e/models.ts +++ b/src/tests/e2e/models.ts @@ -4,32 +4,77 @@ import {join} from 'path' /** - * Set up virtual path intercept for model loading - * Uses Promise.all pattern like routes.spec.ts for proper synchronization + * Setup route intercept for github model loading. The return value is a callback + * to invoke when ready to wait for model ready. * * @param page Playwright page object - * @param path Virtual path to intercept - * @param fixturePath Path to fixture file - * @return Object with intercept helpers and navigation function + * @param githubPathname GitHub proxy pathname, e.g. '/bldrs-ai/test-models/main/ifc/misc/box.ifc' + * @param gotoPathname Pathname to navigate to, + * e.g. '/share/v/gh/.../box.ifc' + * or null if caller should handle navigation. + * @param fixtureFilename Fixture file, e.g. 'box.ifc' + * @return A wait for model ready callback. */ -export async function setupVirtualPathIntercept( +export async function setupGithubPathIntercept( page: Page, - path: string, // e.g. '/share/v/gh/.../Momentum.ifc' - fixturePath: string, // e.g. 'Momentum.ifc' -) { - const sharePrefix = '/share/v/gh' - if (!path.startsWith(sharePrefix)) { - throw new Error(`Path must start with ${sharePrefix}`) + githubPathname: string, + gotoPathname: string | undefined, + fixtureFilename: string, +): Promise<() => Promise> { + if (!githubPathname.startsWith('/')) { + throw new Error(`GitHub proxy pathname must start with '/': ${githubPathname}`) } + const proxyBase = 'https://rawgit.bldrs.dev/r' // since it will be appended to this + const interceptPrefix = `${proxyBase}${githubPathname}` + return await setupRouteIntercept(page, interceptPrefix, gotoPathname, fixtureFilename) +} + - const fixturesDir = 'src/tests/fixtures' - const proxyBase = 'https://rawgit.bldrs.dev.msw/model' - // --- Proxy intercept (serve the IFC bytes) ------------------------------- - const ghPath = path.substring(sharePrefix.length) // keep Cypress logic - const interceptUrl = `${proxyBase}${ghPath}` +/** + * Setup route intercept for google drive model loading. The return value is a callback + * to invoke when ready to wait for model ready. + * + * @param page Playwright page object + * @param googleDriveFildId Google Drive file ID, e.g. '1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO' + * @param gotoPathname Pathname to navigate to, + * e.g. '/share/v/g/https://drive.google.com/file/d/1sWR7x4BZ-a8tIDZ0ICo0woR2KJ_rHCSO/view' + * or null if caller should handle navigation. + * @param fixtureFilename Fixture file, e.g. 'box.ifc' + * @return A wait for model ready callback. + */ +export async function setupGoogleDrivePathIntercept( + page: Page, + googleDriveFildId: string, + gotoPathname: string | undefined, + fixtureFilename: string, // e.g. 'Momentum.ifc' +): Promise<() => Promise> { + const interceptPrefix = `https://www.googleapis.com/drive/v3/files/${googleDriveFildId}` + return await setupRouteIntercept(page, interceptPrefix, gotoPathname, fixtureFilename) +} - await page.route(`${interceptUrl}*`, async (route) => { - const body = await readFile(join(fixturesDir, fixturePath)) + +// Don't export this function, it's internal helper for +// setupGithubPathIntercept and setupGoogleDrivePathIntercept. +/** + * Setup route intercept for model loading. The return value is a callback + * to invoke when ready to wait for model ready. + * + * @param page Playwright page object + * @param interceptPrefix Virtual path to intercept + * @param gotoPathname Pathname to navigate to, e.g. + * '/share/v/g/.../box.ifc' + * '/share/v/gh/.../box.ifc' + * or null if caller should handle navigation. + * @param fixtureFilename Fixture file, e.g. 'box.ifc' + * @return A wait for model ready callback. + */ +async function setupRouteIntercept( + page: Page, interceptPrefix: string, gotoPathname: string | undefined, fixtureFilename: string): + Promise<() => Promise> { + const interceptRoute = `${interceptPrefix}*` + await page.route(interceptRoute, async (route) => { + const fixturesDir = 'src/tests/fixtures' + const body = await readFile(join(fixturesDir, fixtureFilename)) await route.fulfill({ status: 200, headers: {'content-type': 'application/octet-stream'}, @@ -37,21 +82,11 @@ export async function setupVirtualPathIntercept( }) }) - // Return helpers that use Promise.all pattern like routes.spec.ts - const navigateAndWaitForModel = async () => { - const [response] = await Promise.all([ - page.waitForResponse((r) => r.url().startsWith(interceptUrl)), - page.goto(path, {waitUntil: 'domcontentloaded'}), + return async () => { + await Promise.all([ + page.waitForResponse((r) => r.url().startsWith(interceptPrefix)), + gotoPathname ? page.goto(gotoPathname, {waitUntil: 'domcontentloaded'}) : Promise.resolve(), ]) - return response - } - - return { - interceptUrl, - navigateAndWaitForModel, - // Legacy helpers for backwards compatibility - waitForModelRequest: () => page.waitForRequest((r) => r.url().startsWith(interceptUrl)), - waitForModelResponse: () => page.waitForResponse((r) => r.url().startsWith(interceptUrl)), } } diff --git a/src/tests/fixtures/Momentum.ifc b/src/tests/fixtures/test-models/ifc/Momentum.ifc similarity index 100% rename from src/tests/fixtures/Momentum.ifc rename to src/tests/fixtures/test-models/ifc/Momentum.ifc diff --git a/src/tests/fixtures/box.ifc b/src/tests/fixtures/test-models/ifc/misc/box.ifc similarity index 72% rename from src/tests/fixtures/box.ifc rename to src/tests/fixtures/test-models/ifc/misc/box.ifc index b5789ca66..7c6091a87 100644 --- a/src/tests/fixtures/box.ifc +++ b/src/tests/fixtures/test-models/ifc/misc/box.ifc @@ -1,18 +1,18 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('IFC4'),'2;1'); -FILE_NAME('example.ifc','2018-08-8',(''),(''),'','',''); +FILE_NAME('box.ifc','2025-10-29T19:20:00',('Architect'),('Bldrs Inc'),'Expressly written','Tweaked an old test file in emacs.','The Architect'); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#100=IFCPROJECT('UUID-Project',$,'Proxy with extruded box',$,$,$,$,(#201),#301); +#100=IFCPROJECT('UUID-Project',$,'Test Box',$,$,$,$,(#201),#301); #201=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,$,$); #202=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#201,$,.MODEL_VIEW.,$); #301=IFCUNITASSIGNMENT((#311)); #311=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); #500=IFCBUILDING('UUID-building',$,'Test Building',$,$,#511,$,$,.ELEMENT.,$,$,$); #519=IFCRELAGGREGATES('UUID-RelAggregates',$,$,$,#100,(#500)); -#1000=IFCBUILDINGELEMENTPROXY('UUID-Proxy',$,'P-1','sample proxy',$,$,#1010,$,$); +#1000=IFCBUILDINGELEMENTPROXY('UUID-Proxy',$,'The Box','Box using extruded area solid',$,$,#1010,$,$); #1010=IFCPRODUCTDEFINITIONSHAPE($,$,(#1020)); #1020=IFCSHAPEREPRESENTATION(#202,'Body','SweptSolid',(#1021)); #1021=IFCEXTRUDEDAREASOLID(#1022,$,#1034,1.); diff --git a/src/theme/Components.js b/src/theme/Components.js index 4e8d3366f..083637965 100644 --- a/src/theme/Components.js +++ b/src/theme/Components.js @@ -5,6 +5,31 @@ */ export function getComponentOverrides(palette, typography) { return { + MuiAlert: { + styleOverrides: { + root: { + // Strong break-all behavior for potentially long error messages. + 'overflowWrap': 'anywhere', + 'whiteSpace': 'normal', + 'overflowY': 'hidden', + '& .MuiAlert-message': { + textAlign: 'left', + overflowY: 'scroll', + minHeight: '3em', + maxHeight: '8.8em', // cuts last line to make overflow visible + }, + '& p': { + marginTop: '0px', + }, + '& ul': { + paddingLeft: '1em', + }, + '& ol': { + paddingLeft: '1em', + }, + }, + }, + }, MuiAutocomplete: { styleOverrides: { root: { diff --git a/src/utils/oom.js b/src/utils/oom.js deleted file mode 100644 index 2dae1dd37..000000000 --- a/src/utils/oom.js +++ /dev/null @@ -1,32 +0,0 @@ -// Centralized Out Of Memory (OOM) heuristics. -// NOTE: Keep patterns lowercase. Consumers should lowercase their message before matching. - -export const OOM_PATTERNS = [ - 'out of memory', - 'wasm memory', - 'memory allocate', - 'cannot enlarge memory', - 'array buffer allocation failed', - 'could not allocate', - 'javascript heap', - 'insufficient memory', - 'allocation failed - process out of memory', -] - -/** - * Heuristically determine whether an error represents an out-of-memory condition. - * - * @param {any} err - * @return {boolean} - */ -export function isOutOfMemoryError(err) { - if (!err) { - return false - } - try { - const msg = (err && (err.message || err.toString() || ''))?.toLowerCase?.() || '' - return OOM_PATTERNS.some((p) => msg.includes(p)) - } catch (_) { - return false - } -}