Skip to content

Commit 27ce318

Browse files
authored
Bot structure update (medplum#2528)
* Checkpoint * Updated editor and deploy step * Fixed lambda deploy/execute flow * Checkpoint * Fixed code smell * VM Context bot working end-to-end * Tests * Tests * Tests * Tests * AuditEvent triggers and destinations * Fixed CORS * Fixed code editor initial load bug * Fixed bot editor tests * Fixed build error
1 parent 2bb5373 commit 27ce318

File tree

23 files changed

+761
-148
lines changed

23 files changed

+761
-148
lines changed

packages/app/src/resource/BotEditor.test.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MantineProvider } from '@mantine/core';
22
import { Notifications } from '@mantine/notifications';
33
import { allOk, badRequest } from '@medplum/core';
4+
import { Bot } from '@medplum/fhirtypes';
45
import { MockClient } from '@medplum/mock';
56
import { MedplumProvider } from '@medplum/react';
67
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
@@ -12,7 +13,10 @@ let medplum: MockClient;
1213

1314
describe('BotEditor', () => {
1415
async function setup(url: string): Promise<void> {
15-
medplum = new MockClient();
16+
if (!medplum) {
17+
medplum = new MockClient();
18+
jest.spyOn(medplum, 'download').mockImplementation(async () => ({ text: async () => 'test' } as unknown as Blob));
19+
}
1620

1721
// Mock bot operations
1822
medplum.router.router.add('POST', 'Bot/:id/$deploy', async () => [allOk]);
@@ -49,6 +53,7 @@ describe('BotEditor', () => {
4953
test('Bot editor', async () => {
5054
await setup('/Bot/123/editor');
5155
await waitFor(() => screen.getByText('Editor'));
56+
await waitFor(() => screen.getByTestId('code-frame'));
5257
expect(screen.getByText('Editor')).toBeInTheDocument();
5358

5459
await act(async () => {
@@ -197,4 +202,38 @@ describe('BotEditor', () => {
197202

198203
expect(screen.getByText('Error')).toBeInTheDocument();
199204
});
205+
206+
test('Legacy bot', async () => {
207+
// Bots now use "sourceCode" and "executableCode" instead of "code"
208+
// While "code" is deprecated, it is still supported for legacy bots
209+
210+
// Create a Bot with "code" instead of "sourceCode" and "executableCode"
211+
medplum = new MockClient();
212+
const legacyBot = await medplum.createResource<Bot>({
213+
resourceType: 'Bot',
214+
code: 'console.log("foo");',
215+
});
216+
217+
await setup(`/Bot/${legacyBot.id}/editor`);
218+
await waitFor(() => screen.getByText('Save'));
219+
220+
// Mock the code frame
221+
(screen.getByTestId<HTMLIFrameElement>('code-frame').contentWindow as Window).postMessage = (
222+
_message: any,
223+
_targetOrigin: any,
224+
transfer?: Transferable[]
225+
) => {
226+
(transfer?.[0] as MessagePort).postMessage({ result: 'console.log("foo");' });
227+
};
228+
229+
await act(async () => {
230+
fireEvent.click(screen.getByText('Save'));
231+
});
232+
233+
expect(screen.getByText('Saved')).toBeInTheDocument();
234+
235+
const check = await medplum.readResource('Bot', legacyBot.id as string);
236+
expect(check.sourceCode).toBeDefined();
237+
expect(check.sourceCode?.url).toBeDefined();
238+
});
200239
});

packages/app/src/resource/BotEditor.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Button, Grid, Group, Paper } from '@mantine/core';
22
import { showNotification } from '@mantine/notifications';
3-
import { normalizeErrorString } from '@medplum/core';
3+
import { isUUID, MedplumClient, normalizeErrorString, PatchOperation } from '@medplum/core';
44
import { Bot } from '@medplum/fhirtypes';
55
import { useMedplum } from '@medplum/react';
66
import { IconCloudUpload, IconDeviceFloppy, IconPlayerPlay } from '@tabler/icons-react';
@@ -14,6 +14,7 @@ export function BotEditor(): JSX.Element | null {
1414
const medplum = useMedplum();
1515
const { id } = useParams() as { id: string };
1616
const [bot, setBot] = useState<Bot>();
17+
const [defaultCode, setDefaultCode] = useState<string | undefined>(undefined);
1718
const codeFrameRef = useRef<HTMLIFrameElement>(null);
1819
const inputFrameRef = useRef<HTMLIFrameElement>(null);
1920
const outputFrameRef = useRef<HTMLIFrameElement>(null);
@@ -22,7 +23,10 @@ export function BotEditor(): JSX.Element | null {
2223
useEffect(() => {
2324
medplum
2425
.readResource('Bot', id)
25-
.then(setBot)
26+
.then(async (newBot: Bot) => {
27+
setBot(newBot);
28+
setDefaultCode(await getBotCode(medplum, newBot));
29+
})
2630
.catch((err) => showNotification({ color: 'red', message: normalizeErrorString(err) }));
2731
}, [medplum, id]);
2832

@@ -46,21 +50,30 @@ export function BotEditor(): JSX.Element | null {
4650
setLoading(true);
4751
try {
4852
const code = await getCode();
49-
await medplum.patchResource('Bot', id, [
50-
{
53+
const sourceCode = await medplum.createAttachment(code, 'index.ts', 'text/typescript');
54+
const operations: PatchOperation[] = [];
55+
if (bot?.sourceCode) {
56+
operations.push({
5157
op: 'replace',
52-
path: '/code',
53-
value: code,
54-
},
55-
]);
58+
path: '/sourceCode',
59+
value: sourceCode,
60+
});
61+
} else {
62+
operations.push({
63+
op: 'add',
64+
path: '/sourceCode',
65+
value: sourceCode,
66+
});
67+
}
68+
await medplum.patchResource('Bot', id, operations);
5669
showNotification({ color: 'green', message: 'Saved' });
5770
} catch (err) {
5871
showNotification({ color: 'red', message: normalizeErrorString(err) });
5972
} finally {
6073
setLoading(false);
6174
}
6275
},
63-
[medplum, id, getCode]
76+
[medplum, id, bot, getCode]
6477
);
6578

6679
const deployBot = useCallback(
@@ -103,7 +116,7 @@ export function BotEditor(): JSX.Element | null {
103116
[medplum, id, getSampleInput]
104117
);
105118

106-
if (!bot) {
119+
if (!bot || defaultCode === undefined) {
107120
return null;
108121
}
109122

@@ -116,7 +129,7 @@ export function BotEditor(): JSX.Element | null {
116129
language="typescript"
117130
module="commonjs"
118131
testId="code-frame"
119-
defaultValue={bot.code || ''}
132+
defaultValue={defaultCode}
120133
minHeight="528px"
121134
/>
122135
<Group position="right" spacing="xs">
@@ -161,3 +174,17 @@ export function BotEditor(): JSX.Element | null {
161174
</Grid>
162175
);
163176
}
177+
178+
async function getBotCode(medplum: MedplumClient, bot: Bot): Promise<string> {
179+
if (bot.sourceCode?.url) {
180+
// Medplum storage service does not allow CORS requests for security reasons.
181+
// So instead, we have to use the FHIR Binary API to fetch the source code.
182+
// Example: https://storage.staging.medplum.com/binary/272a11dc-5b01-4c05-a14e-5bf53117e1e9/69303e8d-36f2-4417-b09b-60c15f221b09?Expires=...
183+
// The Binary ID is the first UUID in the URL.
184+
const binaryId = bot.sourceCode.url?.split('/')?.find(isUUID) as string;
185+
const blob = await medplum.download(medplum.fhirUrl('Binary', binaryId));
186+
return blob.text();
187+
}
188+
189+
return bot.code ?? '';
190+
}
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import React from 'react';
1+
import React, { RefObject } from 'react';
22
import { sendCommand } from '../utils';
33

44
export interface CodeEditorProps {
55
language: 'typescript' | 'json';
66
module?: 'commonjs' | 'esnext';
7-
defaultValue: string;
8-
iframeRef?: React.RefObject<HTMLIFrameElement>;
7+
defaultValue?: string;
8+
iframeRef: RefObject<HTMLIFrameElement>;
99
testId?: string;
1010
minHeight?: string;
1111
}
@@ -16,14 +16,17 @@ export function CodeEditor(props: CodeEditorProps): JSX.Element {
1616
if (props.module) {
1717
url.searchParams.set('module', props.module);
1818
}
19+
1920
return (
2021
<iframe
2122
frameBorder="0"
2223
src={url.toString()}
2324
style={{ width: '100%', height: '100%', minHeight: props.minHeight }}
2425
ref={props.iframeRef}
2526
data-testid={props.testId}
26-
onLoad={(e) => sendCommand(e.currentTarget as HTMLIFrameElement, { command: 'setValue', value: code })}
27+
onLoad={(e) => {
28+
sendCommand(e.currentTarget as HTMLIFrameElement, { command: 'setValue', value: code }).catch(console.error);
29+
}}
2730
/>
2831
);
2932
}

packages/cli/src/aws/update-app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,17 @@ async function uploadAppToS3(tmpDir: string, bucketName: string): Promise<void>
133133
['css/**/*.css.map', 'application/json', true],
134134
['img/**/*.png', 'image/png', true],
135135
['img/**/*.svg', 'image/svg+xml', true],
136-
['js/**/*.js', 'application/javascript', true],
136+
['js/**/*.js', 'text/javascript', true],
137137
['js/**/*.js.map', 'application/json', true],
138138
['js/**/*.txt', 'text/plain', true],
139139
['favicon.ico', 'image/vnd.microsoft.icon', true],
140140
['robots.txt', 'text/plain', true],
141-
['workbox-*.js', 'application/javascript', true],
141+
['workbox-*.js', 'text/javascript', true],
142142
['workbox-*.js.map', 'application/json', true],
143143

144144
// Not cached
145145
['manifest.webmanifest', 'application/manifest+json', false],
146-
['service-worker.js', 'application/javascript', false],
146+
['service-worker.js', 'text/javascript', false],
147147
['service-worker.js.map', 'application/json', false],
148148
['index.html', 'text/html', false],
149149
];

packages/cli/src/bot.test.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ describe('CLI Bots', () => {
110110
await main(['node', 'index.js', 'bot', 'save', 'hello-world']);
111111
expect(console.log).toBeCalledWith(expect.stringMatching(/Success/));
112112
const check = await medplum.readResource('Bot', bot.id as string);
113-
expect(check.code).toBeDefined();
114-
expect(check.code).not.toEqual('');
113+
expect(check.code).toBeUndefined();
114+
expect(check.sourceCode).toBeDefined();
115115
});
116116

117117
test('Deploy bot success', async () => {
@@ -137,8 +137,34 @@ describe('CLI Bots', () => {
137137
await main(['node', 'index.js', 'bot', 'deploy', 'hello-world']);
138138
expect(console.log).toBeCalledWith(expect.stringMatching(/Success/));
139139
const check = await medplum.readResource('Bot', bot.id as string);
140-
expect(check.code).toBeDefined();
141-
expect(check.code).not.toEqual('');
140+
expect(check.code).toBeUndefined();
141+
expect(check.sourceCode).toBeDefined();
142+
});
143+
144+
test('Deploy bot without dist success', async () => {
145+
// Create the bot
146+
const bot = await medplum.createResource<Bot>({ resourceType: 'Bot' });
147+
expect(bot.code).toBeUndefined();
148+
149+
// Setup bot config
150+
(fs.existsSync as unknown as jest.Mock).mockReturnValue(true);
151+
(fs.readFileSync as unknown as jest.Mock).mockReturnValue(
152+
JSON.stringify({
153+
bots: [
154+
{
155+
name: 'hello-world',
156+
id: bot.id,
157+
source: 'src/hello-world.ts',
158+
},
159+
],
160+
})
161+
);
162+
163+
await main(['node', 'index.js', 'bot', 'deploy', 'hello-world']);
164+
expect(console.log).toBeCalledWith(expect.stringMatching(/Success/));
165+
const check = await medplum.readResource('Bot', bot.id as string);
166+
expect(check.code).toBeUndefined();
167+
expect(check.sourceCode).toBeDefined();
142168
});
143169

144170
test('Deploy bot for multiple bot with wildcards ', async () => {
@@ -294,8 +320,8 @@ describe('CLI Bots', () => {
294320
await main(['node', 'index.js', 'save-bot', 'hello-world']);
295321
expect(console.log).toBeCalledWith(expect.stringMatching(/Success/));
296322
const check = await medplum.readResource('Bot', bot.id as string);
297-
expect(check.code).toBeDefined();
298-
expect(check.code).not.toEqual('');
323+
expect(check.code).toBeUndefined();
324+
expect(check.sourceCode).toBeDefined();
299325
});
300326

301327
test('Deprecate Deploy bot success', async () => {
@@ -321,8 +347,8 @@ describe('CLI Bots', () => {
321347
await main(['node', 'index.js', 'deploy-bot', 'hello-world']);
322348
expect(console.log).toBeCalledWith(expect.stringMatching(/Success/));
323349
const check = await medplum.readResource('Bot', bot.id as string);
324-
expect(check.code).toBeDefined();
325-
expect(check.code).not.toEqual('');
350+
expect(check.code).toBeUndefined();
351+
expect(check.sourceCode).toBeDefined();
326352
});
327353

328354
test('Deprecate Deploy bot for multiple bot with wildcards ', async () => {

packages/cli/src/utils.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Writable } from 'stream';
22
import tar from 'tar';
3-
import { safeTarExtractor } from './utils';
3+
import { getCodeContentType, safeTarExtractor } from './utils';
44

55
jest.mock('tar', () => ({
66
x: jest.fn(),
@@ -52,4 +52,17 @@ describe('CLI utils', () => {
5252
expect((err as Error).message).toEqual('Tar extractor reached max size');
5353
}
5454
});
55+
56+
test('getCodeContentType', () => {
57+
expect(getCodeContentType('foo.cjs')).toEqual('text/javascript');
58+
expect(getCodeContentType('foo.js')).toEqual('text/javascript');
59+
expect(getCodeContentType('foo.mjs')).toEqual('text/javascript');
60+
61+
expect(getCodeContentType('foo.cts')).toEqual('text/typescript');
62+
expect(getCodeContentType('foo.mts')).toEqual('text/typescript');
63+
expect(getCodeContentType('foo.ts')).toEqual('text/typescript');
64+
65+
expect(getCodeContentType('foo.txt')).toEqual('text/plain');
66+
expect(getCodeContentType('foo')).toEqual('text/plain');
67+
});
5568
});

packages/cli/src/utils.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MedplumClient } from '@medplum/core';
22
import { Bot, Extension, OperationOutcome } from '@medplum/fhirtypes';
33
import { existsSync, readFileSync, writeFile } from 'fs';
4-
import { resolve } from 'path';
4+
import { basename, extname, resolve } from 'path';
55
import internal from 'stream';
66
import tar from 'tar';
77

@@ -26,29 +26,30 @@ export function prettyPrint(input: unknown): void {
2626
}
2727

2828
export async function saveBot(medplum: MedplumClient, botConfig: MedplumBotConfig, bot: Bot): Promise<void> {
29-
const code = readFileContents(botConfig.source);
29+
const codePath = botConfig.source;
30+
const code = readFileContents(codePath);
3031
if (!code) {
3132
return;
3233
}
3334

3435
try {
35-
console.log('Update bot code.....');
36+
console.log('Saving source code...');
37+
const sourceCode = await medplum.createAttachment(code, basename(codePath), getCodeContentType(codePath));
38+
39+
console.log('Updating bot.....');
3640
const updateResult = await medplum.updateResource({
3741
...bot,
38-
code,
42+
sourceCode,
3943
});
40-
if (!updateResult) {
41-
console.log('Bot not modified');
42-
} else {
43-
console.log('Success! New bot version: ' + updateResult.meta?.versionId);
44-
}
44+
console.log('Success! New bot version: ' + updateResult.meta?.versionId);
4545
} catch (err) {
4646
console.log('Update error: ', err);
4747
}
4848
}
4949

5050
export async function deployBot(medplum: MedplumClient, botConfig: MedplumBotConfig, bot: Bot): Promise<void> {
51-
const code = readFileContents(botConfig.dist ?? botConfig.source);
51+
const codePath = botConfig.dist ?? botConfig.source;
52+
const code = readFileContents(codePath);
5253
if (!code) {
5354
return;
5455
}
@@ -180,3 +181,14 @@ export function getUnsupportedExtension(): Extension {
180181
],
181182
};
182183
}
184+
185+
export function getCodeContentType(filename: string): string {
186+
const ext = extname(filename).toLowerCase();
187+
if (['.cjs', '.mjs', '.js'].includes(ext)) {
188+
return 'text/javascript';
189+
}
190+
if (['.cts', '.mts', '.ts'].includes(ext)) {
191+
return 'text/typescript';
192+
}
193+
return 'text/plain';
194+
}

0 commit comments

Comments
 (0)