Skip to content

Commit 3930cc3

Browse files
Merge branch 'develop' into fix/notifier-sending-incomplete-data
2 parents 422981e + a1c99df commit 3930cc3

31 files changed

+1649
-6
lines changed

.changeset/thirty-experts-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@rocket.chat/ui-client': minor
3+
---
4+
5+
Adds Wizard component to ui-client package

.changeset/unlucky-poets-decide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/apps-engine': patch
3+
'@rocket.chat/meteor': patch
4+
---
5+
6+
Fixes an issue that would leave an app in an unrecoverable state if the installation failed during the construction of the runtime
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
2+
import type { ReactNode } from 'react';
3+
4+
type WizardActionsProps = {
5+
children: ReactNode;
6+
};
7+
8+
const WizardActions = ({ children }: WizardActionsProps) => {
9+
return (
10+
<Box is={ButtonGroup} mbs={24} display='flex' justifyContent='end'>
11+
{children}
12+
</Box>
13+
);
14+
};
15+
16+
export default WizardActions;

packages/apps-engine/src/server/AppManager.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,9 +588,15 @@ export class AppManager {
588588
return aff;
589589
}
590590

591-
// Now that is has all been compiled, let's get the
592-
// the App instance from the source.
593-
const app = await this.getCompiler().toSandBox(this, descriptor, result);
591+
let app: ProxiedApp;
592+
593+
try {
594+
app = await this.getCompiler().toSandBox(this, descriptor, result);
595+
} catch (error) {
596+
await Promise.all(undoSteps.map((undoer) => undoer()));
597+
598+
throw error;
599+
}
594600

595601
undoSteps.push(() =>
596602
this.getRuntime()

packages/apps-engine/src/server/managers/AppRuntimeManager.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ export type ExecRequestOptions = {
1717
timeout?: number;
1818
};
1919

20+
const defaultRuntimeFactory = (manager: AppManager, appPackage: IParseAppPackageResult, storageItem: IAppStorageItem) =>
21+
new DenoRuntimeSubprocessController(manager, appPackage, storageItem);
22+
2023
export class AppRuntimeManager {
2124
private readonly subprocesses: Record<string, DenoRuntimeSubprocessController> = {};
2225

23-
constructor(private readonly manager: AppManager) {}
26+
constructor(
27+
private readonly manager: AppManager,
28+
private readonly runtimeFactory = defaultRuntimeFactory,
29+
) {}
2430

2531
public async startRuntimeForApp(
2632
appPackage: IParseAppPackageResult,
@@ -33,9 +39,16 @@ export class AppRuntimeManager {
3339
throw new Error('App already has an associated runtime');
3440
}
3541

36-
this.subprocesses[appId] = new DenoRuntimeSubprocessController(this.manager, appPackage, storageItem);
42+
this.subprocesses[appId] = this.runtimeFactory(this.manager, appPackage, storageItem);
3743

38-
await this.subprocesses[appId].setupApp();
44+
try {
45+
await this.subprocesses[appId].setupApp();
46+
} catch (error) {
47+
const subprocess = this.subprocesses[appId];
48+
delete this.subprocesses[appId];
49+
await subprocess.stopApp();
50+
throw error;
51+
}
3952

4053
return this.subprocesses[appId];
4154
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { AsyncTest, Expect, Setup, SetupFixture, SpyOn } from 'alsatian';
2+
3+
import { AppStatus } from '../../../src/definition/AppStatus';
4+
import type { AppManager } from '../../../src/server/AppManager';
5+
import type { IParseAppPackageResult } from '../../../src/server/compiler';
6+
import { AppRuntimeManager } from '../../../src/server/managers/AppRuntimeManager';
7+
import type { DenoRuntimeSubprocessController } from '../../../src/server/runtime/deno/AppsEngineDenoRuntime';
8+
import type { IAppStorageItem } from '../../../src/server/storage';
9+
10+
export class AppRuntimeManagerTestFixture {
11+
private mockManager: AppManager;
12+
13+
private runtimeManager: AppRuntimeManager;
14+
15+
private mockAppPackage: IParseAppPackageResult;
16+
17+
private mockStorageItem: IAppStorageItem;
18+
19+
private mockSubprocessController: DenoRuntimeSubprocessController;
20+
21+
@SetupFixture
22+
public setupFixture() {
23+
this.mockManager = {
24+
getAccessorManager() {
25+
return {} as any;
26+
},
27+
getApiManager() {
28+
return {} as any;
29+
},
30+
getLogStorage() {
31+
return {} as any;
32+
},
33+
getBridges() {
34+
return {} as any;
35+
},
36+
} as AppManager;
37+
38+
this.mockAppPackage = {
39+
info: {
40+
id: 'test-app',
41+
name: 'Test App',
42+
nameSlug: 'test-app',
43+
version: '1.0.0',
44+
description: 'Test app for unit testing',
45+
author: {
46+
name: 'Test Author',
47+
homepage: 'https://test.com',
48+
support: 'https://test.com/support',
49+
},
50+
permissions: [],
51+
requiredApiVersion: '1.0.0',
52+
classFile: 'main.js',
53+
iconFile: 'icon.png',
54+
implements: [],
55+
},
56+
files: {
57+
'main.js': 'console.log("Hello World");',
58+
},
59+
languageContent: {} as unknown as IParseAppPackageResult['languageContent'],
60+
implemented: {} as unknown as IParseAppPackageResult['implemented'],
61+
} as IParseAppPackageResult;
62+
63+
this.mockStorageItem = {
64+
id: 'test-app',
65+
status: AppStatus.MANUALLY_ENABLED,
66+
info: this.mockAppPackage.info,
67+
createdAt: new Date(),
68+
updatedAt: new Date(),
69+
} as IAppStorageItem;
70+
71+
this.mockSubprocessController = {
72+
async setupApp() {
73+
return Promise.resolve();
74+
},
75+
76+
async stopApp() {
77+
return Promise.resolve();
78+
},
79+
80+
getAppId() {
81+
return 'test-app';
82+
},
83+
} as DenoRuntimeSubprocessController;
84+
}
85+
86+
@Setup
87+
public setup() {
88+
this.runtimeManager = new AppRuntimeManager(this.mockManager, () => this.mockSubprocessController);
89+
}
90+
91+
@AsyncTest('Starts runtime for app successfully')
92+
public async startRuntimeForAppSuccessfully() {
93+
await Expect(() => this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem)).not.toThrowAsync();
94+
95+
/* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */
96+
Expect(this.runtimeManager['subprocesses'][this.mockAppPackage.info.id]).toBe(this.mockSubprocessController);
97+
}
98+
99+
@AsyncTest('Fails to start runtime for app that already has a runtime')
100+
public async startMultipleRuntimesForSameApp() {
101+
await Expect(() => this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem)).not.toThrowAsync();
102+
103+
await Expect(() => this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem)).toThrowErrorAsync(
104+
Error,
105+
'App already has an associated runtime',
106+
);
107+
}
108+
109+
@AsyncTest('Starts multiple runtimes for app successfully with force option')
110+
public async startMultipleRuntimesForSameAppWithForceOption() {
111+
await Expect(() => this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem)).not.toThrowAsync();
112+
113+
await Expect(() =>
114+
this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem, { force: true }),
115+
).not.toThrowAsync();
116+
117+
/* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */
118+
Expect(this.runtimeManager['subprocesses'][this.mockAppPackage.info.id]).toBe(this.mockSubprocessController);
119+
}
120+
121+
@AsyncTest()
122+
public async startRuntimeThatFailsToSetup() {
123+
SpyOn(this.mockSubprocessController, 'setupApp').andReturn(Promise.reject(new Error('Nope')));
124+
125+
await Expect(() => this.runtimeManager.startRuntimeForApp(this.mockAppPackage, this.mockStorageItem)).toThrowErrorAsync(Error, 'Nope');
126+
127+
/* eslint-disable-next-line dot-notation -- We need to access the property like this for the compile not to complain */
128+
Expect(this.runtimeManager['subprocesses'][this.mockAppPackage.info.id]).not.toBeDefined();
129+
}
130+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"useDefineForClassFields": false,
5+
"rootDir": "../"
6+
},
7+
"include": ["./**/*"]
8+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { composeStories } from '@storybook/react';
2+
import { render } from '@testing-library/react';
3+
import { axe } from 'jest-axe';
4+
5+
import * as stories from './Wizard.stories';
6+
7+
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
8+
9+
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
10+
const { baseElement } = render(<Story />);
11+
expect(baseElement).toMatchSnapshot();
12+
});
13+
14+
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
15+
const { container } = render(<Story />);
16+
17+
const results = await axe(container);
18+
expect(results).toHaveNoViolations();
19+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Box, Button, States, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
2+
import type { Meta, StoryFn } from '@storybook/react';
3+
4+
import Wizard from './Wizard';
5+
import WizardActions from './WizardActions';
6+
import WizardBackButton from './WizardBackButton';
7+
import WizardContent from './WizardContent';
8+
import WizardNextButton from './WizardNextButton';
9+
import WizardTabs from './WizardTabs';
10+
import { useWizard } from './useWizard';
11+
12+
export default {
13+
title: 'Components/Wizard',
14+
component: Wizard,
15+
subcomponents: {
16+
WizardActions,
17+
WizardBackButton,
18+
WizardContent,
19+
WizardNextButton,
20+
WizardTabs,
21+
},
22+
parameters: {
23+
layout: 'centered',
24+
},
25+
decorators: [
26+
(Story) => (
27+
<Box>
28+
<Story />
29+
</Box>
30+
),
31+
],
32+
} satisfies Meta<typeof Wizard>;
33+
34+
const WizardExample = ({ ordered = false }: { ordered?: boolean }) => {
35+
const wizardApi = useWizard({
36+
steps: [
37+
{ id: 'first-step', title: 'First step' },
38+
{ id: 'second-step', title: 'Second step' },
39+
{ id: 'third-step', title: 'Third step' },
40+
],
41+
});
42+
43+
return (
44+
<Wizard api={wizardApi}>
45+
<WizardTabs ordered={ordered} />
46+
47+
<WizardContent id='first-step'>
48+
<Box width='100%' height='100%' pbs={24}>
49+
<States>
50+
<StatesIcon name='magnifier' />
51+
<StatesTitle>First step</StatesTitle>
52+
</States>
53+
</Box>
54+
55+
<WizardActions>
56+
<WizardNextButton />
57+
</WizardActions>
58+
</WizardContent>
59+
<WizardContent id='second-step'>
60+
<Box width='100%' height='100%' pbs={24}>
61+
<States>
62+
<StatesIcon name='magnifier' />
63+
<StatesTitle>Second step</StatesTitle>
64+
</States>
65+
</Box>
66+
67+
<WizardActions>
68+
<WizardBackButton />
69+
<WizardNextButton />
70+
</WizardActions>
71+
</WizardContent>
72+
<WizardContent id='third-step'>
73+
<Box width='100%' height='100%' pbs={24}>
74+
<States>
75+
<StatesIcon name='magnifier' />
76+
<StatesTitle>Third step</StatesTitle>
77+
</States>
78+
</Box>
79+
80+
<WizardActions>
81+
<Button primary onClick={() => window.alert('Finished!')}>
82+
Finish
83+
</Button>
84+
</WizardActions>
85+
</WizardContent>
86+
</Wizard>
87+
);
88+
};
89+
90+
export const BasicWizard: StoryFn<typeof Wizard> = () => <WizardExample />;
91+
92+
export const OrderedTabsWizard: StoryFn<typeof Wizard> = () => <WizardExample ordered />;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { memo, type ReactNode } from 'react';
2+
3+
import { WizardContext } from './WizardContext';
4+
import type { WizardAPI } from './WizardContext';
5+
6+
type WizardProps = {
7+
api: WizardAPI;
8+
children: ReactNode;
9+
};
10+
11+
const Wizard = ({ children, api }: WizardProps) => (
12+
<WizardContext.Provider value={api}>
13+
<div className='steps-wizard'>{children}</div>
14+
</WizardContext.Provider>
15+
);
16+
17+
export default memo(Wizard);

0 commit comments

Comments
 (0)