@hubspot/ui-extensions-dev-server 0.0.1-prealpha.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # ui-extensions-dev-server
1
+ # UI Extensions – Dev Server
2
+
3
+ Development server to run and test your HubSpot UI Extensions.
2
4
 
3
5
  ## Overview
4
6
  This package contains the cli for running HubSpot UI extensions locally, as well as running a production build for debugging purposes.
package/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { build } = require('vite');
2
2
  const { ROLLUP_OPTIONS } = require('./constants');
3
+ const manifestPlugin = require('./plugins/manifestPlugin');
3
4
 
4
5
  async function buildAllExtensions(config, outputDir) {
5
6
  const extensionKeys = Object.keys(config);
@@ -11,6 +12,9 @@ async function buildAllExtensions(config, outputDir) {
11
12
  outputFileName: data?.output,
12
13
  outputDir,
13
14
  emptyOutDir: i === 0,
15
+ plugins: {
16
+ rollup: [manifestPlugin({ output: data?.output })],
17
+ },
14
18
  });
15
19
  }
16
20
  }
package/config.js CHANGED
@@ -1,14 +1,20 @@
1
1
  const prompts = require('prompts');
2
2
  const logger = require('./logger');
3
3
  const path = require('path');
4
- const { MAIN_APP_CONFIG, PROJECT_CONFIG } = require('./constants');
4
+ const { MAIN_APP_CONFIG } = require('./constants');
5
5
  const { getUrlSafeFileName } = require('./utils');
6
6
 
7
7
  async function getExtensionConfig(configuration, extension) {
8
- if (extension && configuration[extension]) {
9
- const { data } = configuration[extension];
8
+ const extensionOptions = Object.keys(configuration);
9
+
10
+ if (
11
+ (extension && configuration[extension]) ||
12
+ extensionOptions.length === 1
13
+ ) {
14
+ const extensionToRun = extension || extensionOptions[0];
15
+ const { data } = configuration[extensionToRun];
10
16
  return {
11
- key: extension,
17
+ key: extensionToRun,
12
18
  name: data.title,
13
19
  file: data?.module?.file,
14
20
  output: data?.output,
@@ -16,7 +22,6 @@ async function getExtensionConfig(configuration, extension) {
16
22
  };
17
23
  }
18
24
 
19
- const extensionOptions = Object.keys(configuration);
20
25
  const response = await prompts(
21
26
  [
22
27
  {
@@ -65,10 +70,6 @@ function loadConfig() {
65
70
  // will need to be ran from, the extensions directory
66
71
  const configPath = path.join(process.cwd(), `../${MAIN_APP_CONFIG}`);
67
72
 
68
- const projectConfig = _loadRequiredConfigFile(
69
- path.join(process.cwd(), `../../../${PROJECT_CONFIG}`)
70
- );
71
-
72
73
  const mainAppConfig = _loadRequiredConfigFile(configPath);
73
74
 
74
75
  const crmCardsSubConfigFiles = mainAppConfig?.extensions?.crm?.cards;
@@ -107,7 +108,7 @@ function loadConfig() {
107
108
  outputConfig[entryPointPath].data.output = getUrlSafeFileName(
108
109
  entryPointPath
109
110
  );
110
- outputConfig[entryPointPath].data.appName = projectConfig.name;
111
+ outputConfig[entryPointPath].data.appName = mainAppConfig.name;
111
112
  } catch (e) {
112
113
  let errorMessage = e?.message;
113
114
  if (e?.code === 'MODULE_NOT_FOUND') {
package/constants.js CHANGED
@@ -1,7 +1,9 @@
1
1
  const VITE_DEFAULT_PORT = 5173;
2
+ const WEBSOCKET_PORT = 5174;
2
3
  const MAIN_APP_CONFIG = 'app.json';
3
4
  const PROJECT_CONFIG = 'hsproject.json';
4
5
  const OUTPUT_DIR = 'dist';
6
+ const MANIFEST_FILE = 'manifest.json';
5
7
 
6
8
  const ROLLUP_OPTIONS = {
7
9
  // Deps to exclude from the bundle
@@ -16,10 +18,17 @@ const ROLLUP_OPTIONS = {
16
18
  },
17
19
  };
18
20
 
21
+ const EXTENSIONS_MESSAGE_VERSION = 0;
22
+ const WEBSOCKET_MESSAGE_VERSION = 0;
23
+
19
24
  module.exports = {
20
25
  VITE_DEFAULT_PORT,
21
26
  ROLLUP_OPTIONS,
22
27
  MAIN_APP_CONFIG,
23
28
  PROJECT_CONFIG,
24
29
  OUTPUT_DIR,
30
+ MANIFEST_FILE,
31
+ WEBSOCKET_PORT,
32
+ EXTENSIONS_MESSAGE_VERSION,
33
+ WEBSOCKET_MESSAGE_VERSION,
25
34
  };
package/dev.js CHANGED
@@ -1,131 +1,62 @@
1
- const express = require('express');
2
- const chokidar = require('chokidar');
3
- const { WebSocketServer } = require('ws');
4
- const http = require('http');
5
- const logger = require('./logger');
6
- const { build } = require('vite');
1
+ const { createServer } = require('vite');
7
2
  const { getExtensionConfig } = require('./config');
8
- const fs = require('fs');
9
- const path = require('path');
10
- const { ROLLUP_OPTIONS } = require('./constants');
3
+ const startDevServer = require('./server');
4
+ const devBuildPlugin = require('./plugins/devBuildPlugin');
11
5
 
12
- async function _devBuild(config, outputDir, extension) {
13
- const extensionConfig = await getExtensionConfig(config, extension);
14
- build({
15
- define: {
16
- 'process.env.NODE_ENV': JSON.stringify(
17
- process.env.NODE_ENV || 'development'
18
- ),
19
- },
20
- build: {
6
+ async function _createViteDevServer(
7
+ outputDir,
8
+ extensionConfig,
9
+ websocketPort,
10
+ baseMessage
11
+ ) {
12
+ return await createServer({
13
+ appType: 'custom',
14
+ mode: 'development',
15
+ server: {
16
+ middlewareMode: true,
17
+ hmr: {
18
+ port: websocketPort,
19
+ },
21
20
  watch: {
22
- clearScreen: false,
23
- exclude: [
24
- 'node_modules',
25
- 'package.json',
26
- 'package-lock.json',
27
- 'app.json',
28
- ],
21
+ ignored: ['**/src/app/extensions/dist/**/*'],
29
22
  },
30
- lib: {
31
- entry: extensionConfig?.file,
32
- name: extensionConfig?.output,
33
- formats: ['iife'],
34
- fileName: () => extensionConfig?.output,
23
+ },
24
+ build: {
25
+ rollupOptions: {
26
+ input: extensionConfig?.file,
27
+ output: extensionConfig.output,
35
28
  },
36
- rollupOptions: ROLLUP_OPTIONS,
37
- outDir: outputDir,
38
- emptyOutDir: true,
39
- minify: false,
40
29
  },
30
+ plugins: [devBuildPlugin({ extensionConfig, outputDir, baseMessage })],
41
31
  });
42
- return extensionConfig;
43
32
  }
44
33
 
45
- function _startDevServer(outputDir, port, extensionConfig) {
46
- const app = express();
47
- const server = http.createServer(app);
48
-
49
- // Host the OUTPUT_DIR
50
- app.use(express.static(outputDir));
51
-
52
- // Setup websocket server to send messages to browser on bundle update
53
- const wss = new WebSocketServer({ server });
54
-
55
- const callback = `http://localhost:${port}/${extensionConfig?.output}`;
56
-
57
- function broadcast(message) {
58
- if (wss.clients.size === 0) {
59
- logger.warn('No browsers connected to update');
60
- return;
61
- }
62
- wss.clients.forEach(client => {
63
- client.send(JSON.stringify(message));
64
- });
65
- }
66
-
67
- wss.on('connection', client => {
68
- let base64Callback;
69
- try {
70
- base64Callback = fs
71
- .readFileSync(
72
- path.join(process.cwd(), outputDir, extensionConfig?.output)
73
- )
74
- .toString('base64');
75
- } catch (e) {
76
- logger.warn(
77
- 'File not found:',
78
- path.join(process.cwd(), outputDir, extensionConfig?.output)
79
- );
80
- }
81
-
82
- logger.info('Browser connected and listening for bundle updates');
83
- client.send(
84
- JSON.stringify({
85
- event: 'start',
86
- appName: extensionConfig?.appName,
87
- extension: extensionConfig?.name,
88
- callback: base64Callback
89
- ? `data:text/javascript;base64,${base64Callback}`
90
- : undefined,
91
- })
92
- );
93
- });
94
-
95
- // Start the express and websocket servers
96
- server.listen({ port }, () => {
97
- logger.warn(`Listening at ${callback}`);
98
- });
99
-
100
- // Setup a watcher on the dist directory and update broadcast
101
- //to all clients when an event is observed
102
- chokidar.watch(outputDir).on('change', file => {
103
- const base64Callback = fs
104
- .readFileSync(path.join(process.cwd(), file))
105
- .toString('base64');
106
- logger.debug(`${file} updated, reloading extension`);
107
- broadcast({
108
- event: 'update',
109
- appName: extensionConfig?.appName,
110
- extension: extensionConfig?.name,
111
- callback: `data:text/javascript;base64,${base64Callback}`,
112
- });
113
- });
114
-
115
- process.on('SIGINT', () => {
116
- logger.warn('\nSending shutdown signal to connected browser');
117
- broadcast({
118
- event: 'shutdown',
119
- appName: extensionConfig?.appName,
120
- extension: extensionConfig?.name,
121
- });
122
- process.exit(0);
34
+ async function startDevMode(
35
+ config,
36
+ outputDir,
37
+ expressPort,
38
+ webSocketPort,
39
+ extension
40
+ ) {
41
+ const extensionConfig = await getExtensionConfig(config, extension);
42
+ const baseMessage = Object.freeze({
43
+ appName: extensionConfig?.appName,
44
+ title: extensionConfig?.name,
45
+ callback: `http://hslocal.net:${expressPort}/${extensionConfig?.output}`,
123
46
  });
124
- }
125
-
126
- async function startDevMode(config, outputDir, port, extension) {
127
- const extensionConfig = await _devBuild(config, outputDir, extension);
128
- _startDevServer(outputDir, port, extensionConfig);
47
+ const viteDevServer = await _createViteDevServer(
48
+ outputDir,
49
+ extensionConfig,
50
+ webSocketPort,
51
+ baseMessage
52
+ );
53
+ startDevServer(
54
+ outputDir,
55
+ expressPort,
56
+ webSocketPort,
57
+ baseMessage,
58
+ viteDevServer
59
+ );
129
60
  }
130
61
 
131
62
  module.exports = {
package/index.js CHANGED
@@ -16,12 +16,13 @@ async function remoteBuild(root, entryPoint, outputDir) {
16
16
  );
17
17
  }
18
18
 
19
+ const output = getUrlSafeFileName(entryPoint);
19
20
  await buildSingleExtension({
20
21
  file: entryPoint,
21
22
  outputFileName: getUrlSafeFileName(entryPoint),
22
23
  outputDir: outputDir || OUTPUT_DIR,
23
24
  plugins: {
24
- rollup: [manifestPlugin({ minify: true })],
25
+ rollup: [manifestPlugin({ minify: true, output })],
25
26
  },
26
27
  minify: true,
27
28
  root,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/ui-extensions-dev-server",
3
- "version": "0.0.1-prealpha.8",
3
+ "version": "0.2.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,20 +22,23 @@
22
22
  "tests/runTests.js",
23
23
  "tests/testBuild.js",
24
24
  "tests/testDevServer.js",
25
- "plugins/manifestPlugin.js",
26
- "index.js"
25
+ "tests/utils.js",
26
+ "plugins/*",
27
+ "index.js",
28
+ "server.js"
27
29
  ],
28
30
  "license": "MIT",
29
31
  "dependencies": {
30
- "chokidar": "^3.5.3",
32
+ "axios": "^1.4.0",
31
33
  "command-line-args": "^5.2.1",
32
34
  "command-line-usage": "^7.0.1",
33
35
  "console-log-colors": "^0.4.0",
36
+ "cors": "^2.8.5",
34
37
  "express": "^4.18.2",
35
38
  "process": "^0.11.10",
36
39
  "prompts": "^2.4.2",
37
40
  "vite": "^4.0.4",
38
- "ws": "^8.12.1"
41
+ "ws": "^8.13.0"
39
42
  },
40
43
  "bin": {
41
44
  "hs-ui-extensions-dev-server": "run.js"
@@ -56,5 +59,5 @@
56
59
  "optional": true
57
60
  }
58
61
  },
59
- "gitHead": "b5968f431fe29ee9977e6aacdd9b9ab3aa08af0c"
62
+ "gitHead": "ab7ad192e9d98163d2613d77f68677e3b48898aa"
60
63
  }
@@ -0,0 +1,98 @@
1
+ const { ROLLUP_OPTIONS, WEBSOCKET_MESSAGE_VERSION } = require('../constants');
2
+ const { build } = require('vite');
3
+ const manifestPlugin = require('./manifestPlugin');
4
+ const logger = require('../logger');
5
+
6
+ function devBuildPlugin(options = {}) {
7
+ const { extensionConfig, outputDir, baseMessage } = options;
8
+ const versionedBaseMessage = {
9
+ ...baseMessage,
10
+ version: WEBSOCKET_MESSAGE_VERSION,
11
+ };
12
+
13
+ const devBuild = async () => {
14
+ try {
15
+ await build({
16
+ mode: 'development',
17
+ define: {
18
+ 'process.env.NODE_ENV': JSON.stringify(
19
+ process.env.NODE_ENV || 'development'
20
+ ),
21
+ },
22
+ build: {
23
+ lib: {
24
+ entry: extensionConfig?.file,
25
+ name: extensionConfig?.output,
26
+ formats: ['iife'],
27
+ fileName: () => extensionConfig?.output,
28
+ },
29
+ rollupOptions: {
30
+ ...ROLLUP_OPTIONS,
31
+ plugins: [
32
+ ...(ROLLUP_OPTIONS.plugins || []),
33
+ manifestPlugin({
34
+ minify: false,
35
+ output: extensionConfig?.output,
36
+ }),
37
+ ],
38
+ output: {
39
+ ...ROLLUP_OPTIONS.output,
40
+ sourcemap: 'inline',
41
+ },
42
+ },
43
+ outDir: outputDir,
44
+ emptyOutDir: true,
45
+ minify: false,
46
+ },
47
+ });
48
+ } catch (e) {
49
+ logger.error(e.message);
50
+ }
51
+ };
52
+
53
+ let localServer;
54
+ return {
55
+ name: 'ui-extensibility-dev-build-plugin',
56
+ enforce: 'pre',
57
+ configureServer: async server => {
58
+ // Store a reference to the server to be used in hooks that don't get the server injected
59
+ // See https://vitejs.dev/guide/api-plugin.html#configureserver for information on this pattern
60
+ localServer = server;
61
+ localServer.ws.on('connection', () => {
62
+ logger.info('Browser connected and listening for bundle updates');
63
+ localServer.ws.send({
64
+ ...versionedBaseMessage,
65
+ event: 'start',
66
+ });
67
+ });
68
+ await devBuild();
69
+ },
70
+ handleHotUpdate: async ({ server }) => {
71
+ await devBuild();
72
+ if (server.ws.clients.size) {
73
+ logger.info('Bundle updated, notifying connected browsers');
74
+ } else {
75
+ logger.warn('Bundle updated, no browsers connected to notify');
76
+ }
77
+ server.ws.send({
78
+ ...versionedBaseMessage,
79
+ event: 'update',
80
+ });
81
+ return [];
82
+ },
83
+ buildEnd(error) {
84
+ if (error) {
85
+ logger.error(error);
86
+ }
87
+ logger.warn('Sending shutdown message to connected browsers');
88
+ if (localServer && localServer.ws) {
89
+ localServer.ws.send({
90
+ ...versionedBaseMessage,
91
+ event: 'shutdown',
92
+ });
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ module.exports = devBuildPlugin;
@@ -1,8 +1,9 @@
1
1
  const { readFileSync } = require('fs');
2
2
  const { normalize } = require('path');
3
+ const { MANIFEST_FILE } = require('../constants');
3
4
  const logger = require('../logger');
5
+ const path = require('path');
4
6
 
5
- const DEFAULT_MANIFEST_NAME = 'manifest.json';
6
7
  const PACKAGE_LOCK_FILE = 'package-lock.json';
7
8
  const PACKAGE_FILE = 'package.json';
8
9
  const EXTENSIONS_PATH = 'src/app/extensions/';
@@ -12,15 +13,16 @@ function plugin(options = {}) {
12
13
  name: 'ui-extensions-manifest-generation-plugin',
13
14
  enforce: 'post', // run after default rollup plugins
14
15
  generateBundle(_rollupOptions, bundle) {
15
- const { output = DEFAULT_MANIFEST_NAME, minify = false } = options;
16
+ const { output, minify = false } = options;
16
17
  try {
18
+ const filename = path.parse(output).name;
17
19
  const manifest = _generateManifestContents(bundle);
18
20
  this.emitFile({
19
21
  type: 'asset',
20
22
  source: minify
21
23
  ? JSON.stringify(manifest)
22
24
  : JSON.stringify(manifest, null, 2),
23
- fileName: normalize(output),
25
+ fileName: normalize(`${filename}-${MANIFEST_FILE}`),
24
26
  });
25
27
  } catch (e) {
26
28
  logger.warn(`\nUnable to write manifest file in ${output}, ${e}`);
@@ -35,7 +37,7 @@ function _generateManifestContents(bundle) {
35
37
  };
36
38
 
37
39
  // The keys to bundle are the filename without any path information
38
- const bundles = Object.keys(bundle);
40
+ const bundles = Object.keys(bundle).filter(cur => cur.endsWith('.js'));
39
41
 
40
42
  if (bundles.length === 1) {
41
43
  return {
@@ -81,7 +83,7 @@ function _loadPackageFile() {
81
83
  }
82
84
 
83
85
  function _stripPathPriorToExtDir(filepath) {
84
- return filepath.split(EXTENSIONS_PATH).pop();
86
+ return filepath?.split(EXTENSIONS_PATH).pop();
85
87
  }
86
88
 
87
89
  function _buildModulesInfo(moduleIds, modules) {
package/run.js CHANGED
@@ -3,27 +3,37 @@
3
3
  const { startDevMode } = require('./dev');
4
4
  const { loadConfig } = require('./config');
5
5
  const { buildAllExtensions, buildSingleExtension } = require('./build');
6
- const { VITE_DEFAULT_PORT, OUTPUT_DIR } = require('./constants');
6
+ const {
7
+ VITE_DEFAULT_PORT,
8
+ OUTPUT_DIR,
9
+ WEBSOCKET_PORT,
10
+ } = require('./constants');
7
11
  const { parseArgs, showHelp } = require('./cli');
8
12
  const { getUrlSafeFileName } = require('./utils');
9
13
  const manifestPlugin = require('./plugins/manifestPlugin');
10
14
 
11
- const { DEV_MODE, BUILD_MODE, port, extension, help } = parseArgs();
12
- const PORT = port || VITE_DEFAULT_PORT;
15
+ const { DEV_MODE, BUILD_MODE, extension, help } = parseArgs();
13
16
 
14
17
  if (help || !(DEV_MODE || BUILD_MODE)) {
15
18
  showHelp(OUTPUT_DIR);
16
19
  }
17
20
 
18
21
  if (DEV_MODE) {
19
- startDevMode(loadConfig(), OUTPUT_DIR, PORT, extension);
22
+ startDevMode(
23
+ loadConfig(),
24
+ OUTPUT_DIR,
25
+ VITE_DEFAULT_PORT,
26
+ WEBSOCKET_PORT,
27
+ extension
28
+ );
20
29
  } else if (BUILD_MODE) {
21
30
  if (extension) {
31
+ const output = getUrlSafeFileName(extension);
22
32
  buildSingleExtension({
23
33
  file: extension,
24
- outputFileName: getUrlSafeFileName(extension),
34
+ outputFileName: output,
25
35
  outputDir: OUTPUT_DIR,
26
- plugins: { rollup: [manifestPlugin()] },
36
+ plugins: { rollup: [manifestPlugin({ output })] },
27
37
  });
28
38
  } else {
29
39
  buildAllExtensions(loadConfig(), OUTPUT_DIR);
package/server.js ADDED
@@ -0,0 +1,89 @@
1
+ const express = require('express');
2
+ const logger = require('./logger');
3
+ const path = require('path');
4
+ const cors = require('cors');
5
+ const fs = require('fs');
6
+ const { EXTENSIONS_MESSAGE_VERSION, MANIFEST_FILE } = require('./constants');
7
+
8
+ function startDevServer(
9
+ outputDir,
10
+ expressPort,
11
+ webSocketPort,
12
+ baseMessage,
13
+ viteDevServer
14
+ ) {
15
+ const app = express();
16
+
17
+ // Setup middleware
18
+ app.use(cors());
19
+ app.use(express.static(outputDir));
20
+ _addExtensionsEndpoint(
21
+ app,
22
+ expressPort,
23
+ webSocketPort,
24
+ outputDir,
25
+ baseMessage
26
+ );
27
+
28
+ // Vite middlewares needs to go last because it's greedy and will block other middleware
29
+ app.use(viteDevServer.middlewares);
30
+
31
+ const server = app.listen({ port: expressPort }, () => {
32
+ logger.warn(`Listening at ${baseMessage.callback}`);
33
+ });
34
+
35
+ _configureShutDownHandlers(server, viteDevServer);
36
+ }
37
+
38
+ function _addExtensionsEndpoint(
39
+ server,
40
+ expressPort,
41
+ webSocketPort,
42
+ outputDir,
43
+ baseMessage
44
+ ) {
45
+ const endpoint = '/extensions';
46
+ server.get(endpoint, (_req, res) => {
47
+ try {
48
+ const output = path.parse(baseMessage.callback).name;
49
+ const manifest = JSON.parse(
50
+ fs.readFileSync(
51
+ path.join(process.cwd(), `${outputDir}/${output}-${MANIFEST_FILE}`)
52
+ )
53
+ );
54
+
55
+ const response = {
56
+ websocket: `ws://localhost:${webSocketPort}`,
57
+ version: EXTENSIONS_MESSAGE_VERSION,
58
+ extensions: [
59
+ {
60
+ ...baseMessage,
61
+ manifest,
62
+ },
63
+ ],
64
+ };
65
+ res.status(200).json(response);
66
+ } catch (e) {
67
+ res.status(500).json({
68
+ message: 'Unable to load manifest file',
69
+ });
70
+ }
71
+ });
72
+ logger.warn(`Listening at http://hslocal.net:${expressPort}${endpoint}`);
73
+ }
74
+
75
+ function _configureShutDownHandlers(server, viteDevServer) {
76
+ async function shutdown() {
77
+ logger.warn('\nCleaning up after ourselves...');
78
+ await viteDevServer.pluginContainer.close();
79
+ // Stop new connections to express server
80
+ server.close(() => {});
81
+ logger.info('Clean up done, exiting.');
82
+ process.exit(0);
83
+ }
84
+
85
+ process.on('SIGINT', shutdown);
86
+ process.on('SIGTERM', shutdown);
87
+ }
88
+
89
+ module.exports = startDevServer;
package/tests/runTests.js CHANGED
@@ -3,29 +3,48 @@
3
3
  const { testBuild } = require('./testBuild');
4
4
  const { testDevServer } = require('./testDevServer');
5
5
  const logger = require('../logger');
6
+ const { generateSpec } = require('./utils');
6
7
 
7
8
  let devServerProcess;
8
9
 
10
+ // Overwrite console.debug to only log when in DEBUG mode
11
+ console.debug = (...args) => {
12
+ if (process.env.DEBUG) {
13
+ console.log(...args);
14
+ }
15
+ };
16
+
9
17
  function handleFailure() {
10
18
  if (devServerProcess) {
11
19
  devServerProcess.kill();
12
20
  }
13
21
  }
14
22
 
15
- process.on('SIGINT', () => {
16
- handleFailure();
17
- });
23
+ process.on('SIGINT', handleFailure);
24
+ process.on('SIGTERM', handleFailure);
18
25
 
19
- process.on('uncaughtException', () => {
26
+ process.on('uncaughtException', e => {
27
+ console.error(e);
20
28
  handleFailure();
21
29
  });
22
30
 
23
- try {
24
- testBuild(logger);
25
- testDevServer(logger, devServerProcess);
26
- } catch (e) {
27
- console.error(e.message);
28
- logger.error('Tests failed 😭');
29
- handleFailure();
30
- process.exit(1);
31
- }
31
+ // eslint-disable-next-line no-floating-promise/no-floating-promise
32
+ (async () => {
33
+ try {
34
+ const spec = generateSpec();
35
+
36
+ logger.warn(
37
+ `Running tests for the following entrypoints: [\n\t${spec.build.entrypoints.join(
38
+ ',\n\t'
39
+ )}\n]`
40
+ );
41
+
42
+ await testBuild(logger, spec.build);
43
+ testDevServer(logger, devServerProcess, spec.dev);
44
+ } catch (e) {
45
+ console.error(e.message);
46
+ logger.error('Tests failed 😭');
47
+ handleFailure();
48
+ process.exit(1);
49
+ }
50
+ })();
@@ -2,9 +2,12 @@ const { execSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const assert = require('assert');
5
+ const { generateManifestOutputPair, verifyFileContents } = require('./utils');
5
6
 
6
7
  function _testHelper(command, outputDirFiles) {
7
- execSync(command);
8
+ if (command) {
9
+ execSync(command);
10
+ }
8
11
 
9
12
  // Make sure the files are getting generated in the dist dir
10
13
  const distDir = path.join(process.cwd(), 'dist');
@@ -14,76 +17,64 @@ function _testHelper(command, outputDirFiles) {
14
17
  // Spot check the file contents to make sure they seem ok
15
18
  filesInOutputDir.forEach(file => {
16
19
  const fileContents = fs.readFileSync(path.join(distDir, file)).toString();
17
- if (file === 'manifest.json') {
18
- const manifest = JSON.parse(fileContents);
19
- assert(manifest.entry);
20
- assert(manifest.modules);
21
- assert(manifest.modules.internal);
22
- manifest.modules.internal.forEach(mod => {
23
- assert(mod.module);
24
- assert(mod.renderedExports);
25
- });
26
- assert(manifest.modules.external);
27
- manifest.modules.external.forEach(mod => {
28
- assert(mod.module);
29
- assert(mod.renderedExports);
30
- });
31
- assert(manifest.package);
32
- assert(manifest.package.packages);
33
- } else {
34
- const stringsToSpotCheck = [
35
- '.createRemoteReactComponent',
36
- '.createElement',
37
- 'hubspot.extend',
38
- 'React',
39
- 'RemoteUI',
40
- ];
41
- stringsToSpotCheck.forEach(stringToCheck => {
42
- assert(
43
- fileContents.includes(stringToCheck),
44
- `File ${file} contents should contain: "${stringToCheck}"`
45
- );
46
- });
47
- }
20
+ verifyFileContents(file, fileContents);
48
21
  });
49
22
  }
50
23
 
51
- function testDefaultBuildPath(logger) {
52
- logger.warn('- Test default build path started 🤞');
53
- _testHelper('hs-ui-extensions-dev-server build', [
54
- 'PhoneLines.js',
55
- 'ProgressBarApp.js',
56
- ]);
57
- logger.info('- Test default build path passed 🚀');
24
+ function testBuildAll(logger, entrypoints) {
25
+ const expected = entrypoints
26
+ .reduce((acc, cur) => {
27
+ return acc.concat(generateManifestOutputPair(cur));
28
+ }, [])
29
+ .sort();
30
+ logger.warn('- Test build all extensions started 🤞');
31
+ _testHelper('hs-ui-extensions-dev-server build', expected);
32
+ logger.info('- Test build all extensions passed 🚀');
58
33
  }
59
34
 
60
- function testBuildWithExtensionFlag(logger) {
61
- logger.warn('- Test build with flags started 🤞');
62
- _testHelper(
63
- 'hs-ui-extensions-dev-server build --extension ProgressBarApp.tsx',
64
- ['ProgressBarApp.js', 'manifest.json']
65
- );
66
- logger.info('- Test build with flags passed 🚀');
35
+ function testBuildSingle(logger, entrypoints) {
36
+ entrypoints.forEach(entrypoint => {
37
+ logger.warn(
38
+ `- Test build single extension started for entrypoint ${entrypoint} 🤞`
39
+ );
40
+ _testHelper(
41
+ `hs-ui-extensions-dev-server build --extension ${entrypoint}`,
42
+ generateManifestOutputPair(entrypoint)
43
+ );
44
+ logger.info(
45
+ `- Test build single extension passed for entrypoint ${entrypoint} 🚀`
46
+ );
47
+ });
67
48
  }
68
49
 
69
- function testDefInfraBuildFileName(logger) {
70
- logger.warn('- Test build with entrypoint as arg 🤞');
71
- _testHelper('hs-ui-extensions-remote-build ProgressBarApp.tsx', [
72
- 'ProgressBarApp.js',
73
- 'manifest.json',
74
- ]);
75
- logger.info('- Test build with entrypoint as arg 🚀');
50
+ async function testBuildRemote(logger, entrypoints) {
51
+ const { remoteBuild } = require('../index');
52
+ for (let i = 0; i < entrypoints.length; ++i) {
53
+ const entrypoint = entrypoints[i];
54
+ logger.warn(
55
+ `- Test remoteBuild function started for entrypoint ${entrypoint} 🤞`
56
+ );
57
+
58
+ await remoteBuild(process.cwd(), entrypoint, 'dist');
59
+ _testHelper(null, generateManifestOutputPair(entrypoint));
60
+
61
+ logger.info(
62
+ `- Test remoteBuild function passed for entrypoint ${entrypoint} 🚀`
63
+ );
64
+ }
76
65
  }
77
66
 
78
- function testBuild(logger) {
67
+ async function testBuild(logger, buildSpec) {
68
+ const { entrypoints } = buildSpec;
69
+
79
70
  logger.warn('\nBuild Tests started - External Devs 🤞');
80
- testDefaultBuildPath(logger);
81
- testBuildWithExtensionFlag(logger);
71
+ testBuildAll(logger, entrypoints);
72
+ testBuildSingle(logger, entrypoints);
82
73
  logger.info('Build Tests passed - External Devs🚀');
83
74
 
84
- logger.warn('\nBuild Tests started - Dev Infra 🤞');
85
- testDefInfraBuildFileName(logger);
86
- logger.info('Build Tests passed - Dev Infra 🚀');
75
+ logger.warn('\nBuild Tests started - Remote 🤞');
76
+ await testBuildRemote(logger, entrypoints);
77
+ logger.info('Build Tests passed - Remote 🚀');
87
78
  }
88
79
 
89
80
  module.exports = {
@@ -2,40 +2,92 @@ const { spawn } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const assert = require('assert');
5
- const http = require('http');
5
+ const axios = require('axios');
6
6
  const WebSocket = require('ws');
7
+ const {
8
+ VITE_DEFAULT_PORT,
9
+ WEBSOCKET_PORT,
10
+ WEBSOCKET_MESSAGE_VERSION,
11
+ EXTENSIONS_MESSAGE_VERSION,
12
+ } = require('../constants');
13
+ const { verifyFileContents, generateManifestOutputPair } = require('./utils');
14
+ const { getUrlSafeFileName } = require('../utils');
7
15
 
8
- const testResults = {
16
+ const preShutdownResults = {
9
17
  buildTestPassed: false,
10
- expressTestPassed: false,
18
+ expressStaticTestPassed: false,
19
+ extensionsEndpointPassed: false,
11
20
  webSocketTestPassed: false,
12
21
  };
13
22
 
14
- const port = 5172;
23
+ const postShutdownResults = {
24
+ websocketShutdownReceived: false,
25
+ expressServerShutdown: false,
26
+ };
27
+
28
+ const host = 'http://hslocal.net';
29
+
30
+ let running = false;
15
31
 
16
- function testDevServer(logger, devServerProcess) {
17
- logger.warn('\nDev Server Tests started 🤞');
32
+ function resetResults() {
33
+ Object.keys(preShutdownResults).forEach(result => {
34
+ preShutdownResults[result] = false;
35
+ });
36
+ Object.keys(postShutdownResults).forEach(result => {
37
+ postShutdownResults[result] = false;
38
+ });
39
+ }
18
40
 
41
+ function testDevServer(logger, devServerProcess, specs) {
42
+ const devServerInterval = setInterval(runDevServer, 1000);
43
+ let runCount = 0;
44
+
45
+ function runDevServer() {
46
+ if (running === false && !devServerProcess) {
47
+ resetResults();
48
+ _testDevServer(logger, devServerProcess, specs[runCount]);
49
+ runCount += 1;
50
+ }
51
+ if (runCount === specs.length) {
52
+ clearInterval(devServerInterval);
53
+ }
54
+ }
55
+ }
56
+
57
+ function _testDevServer(logger, devServerProcess, spec) {
58
+ running = true;
59
+ const { entrypoint, expected } = spec;
60
+
61
+ const filename = getUrlSafeFileName(entrypoint);
62
+ const localExpectations = {
63
+ callback: `${host}:${VITE_DEFAULT_PORT}/${filename}`,
64
+ websocketUrl: `ws://localhost:${WEBSOCKET_PORT}`,
65
+ ...expected,
66
+ };
67
+
68
+ logger.warn(`\nDev Server Tests started - ${entrypoint} 🤞`);
19
69
  // We need to use spawn here because it will put the process into the background,
20
70
  // which is required because dev mode is a blocking process and we want to test that
21
71
  // the express server and websocket server are starting properly
22
72
  devServerProcess = spawn('hs-ui-extensions-dev-server', [
23
73
  'dev',
24
74
  '--extension',
25
- 'PhoneLines.tsx',
26
- '--port',
27
- `${port}`,
75
+ entrypoint,
28
76
  ]);
29
77
 
30
78
  devServerProcess.stdout.on('data', buffer => {
31
79
  const data = buffer.toString().toLowerCase();
80
+ console.debug('[Dev Server]:', data);
32
81
  if (data.includes('built in')) {
33
- testBuild(testResults, logger);
82
+ testBuild(logger, entrypoint);
34
83
  }
35
- if (data.includes('listening') && data.includes(`localhost:${port}`)) {
84
+ if (
85
+ data.includes('listening') &&
86
+ data.includes(`${host}:${VITE_DEFAULT_PORT}/extensions`)
87
+ ) {
36
88
  setTimeout(() => {
37
- testExpressServer(testResults, logger);
38
- testWebSocketServer(testResults, logger);
89
+ testExpressServer(logger, localExpectations, filename);
90
+ testWebSocketServer(logger, localExpectations);
39
91
  }, 1000);
40
92
  }
41
93
  });
@@ -49,99 +101,187 @@ function testDevServer(logger, devServerProcess) {
49
101
 
50
102
  // When the process closes make sure we met all the success conditions
51
103
  devServerProcess.on('close', () => {
52
- if (metConditions()) {
53
- logger.info('Dev Server Tests passed 🚀');
104
+ if (metPreShutdownConditions()) {
105
+ logger.info(`- Dev Server pre-shutdown tests passed - ${entrypoint} 🚀`);
106
+ testPostShutdown(logger, filename);
54
107
  } else {
55
- console.log(testResults);
108
+ console.log(preShutdownResults);
56
109
  logger.error('Tests failed 😭');
57
110
  }
58
111
  });
59
112
 
60
- const interval = setInterval(callback, 1000);
61
- let count = 0;
62
- function callback() {
63
- count += 1;
64
- if (metConditions() || count === 5) {
113
+ const preShutdownInterval = setInterval(preShutdownCallback, 1000);
114
+ let preShutdownCheckCount = 0;
115
+ function preShutdownCallback() {
116
+ preShutdownCheckCount += 1;
117
+ if (metPreShutdownConditions() || preShutdownCheckCount === 5) {
118
+ clearInterval(preShutdownInterval);
65
119
  devServerProcess.kill();
66
- clearInterval(interval);
67
120
  }
68
121
  }
69
122
  }
70
123
 
71
- function metConditions() {
124
+ function metPreShutdownConditions() {
72
125
  const {
73
126
  buildTestPassed,
74
- expressTestPassed,
127
+ expressStaticTestPassed,
128
+ extensionsEndpointPassed,
75
129
  webSocketTestPassed,
76
- } = testResults;
77
- return buildTestPassed && expressTestPassed && webSocketTestPassed;
130
+ } = preShutdownResults;
131
+ return (
132
+ buildTestPassed &&
133
+ expressStaticTestPassed &&
134
+ extensionsEndpointPassed &&
135
+ webSocketTestPassed
136
+ );
137
+ }
138
+
139
+ function metPostShutdownConditions() {
140
+ const {
141
+ websocketShutdownReceived,
142
+ expressServerShutdown,
143
+ } = postShutdownResults;
144
+
145
+ return websocketShutdownReceived && expressServerShutdown;
78
146
  }
79
147
 
80
148
  // Test that the files were built in the proper location and spot
81
149
  // check the contents of the files.
82
- function testBuild(results, logger) {
150
+ function testBuild(logger, entryPoint) {
83
151
  // // Make sure the files are getting generated in the dist dir
84
152
  const distDir = path.join(process.cwd(), 'dist');
85
153
  const filesInOutputDir = fs.readdirSync(distDir);
86
- assert.deepStrictEqual(filesInOutputDir, ['PhoneLines.js']);
154
+ const filename = path.parse(entryPoint).name;
155
+ assert.deepStrictEqual(
156
+ filesInOutputDir,
157
+ generateManifestOutputPair(entryPoint)
158
+ );
87
159
  const fileContents = fs
88
- .readFileSync(path.join(distDir, filesInOutputDir[0]))
160
+ .readFileSync(path.join(distDir, `${filename}.js`))
89
161
  .toString();
90
- const stringsToSpotCheck = [
91
- '.createRemoteReactComponent',
92
- '.createElement',
93
- 'hubspot.extend',
94
- 'React',
95
- 'RemoteUI',
96
- ];
97
- stringsToSpotCheck.forEach(stringToCheck => {
98
- assert(
99
- fileContents.includes(stringToCheck),
100
- `File ${filesInOutputDir[0]} contents should contain: "${stringToCheck}"`
101
- );
102
- });
162
+ verifyFileContents(`${filename}.js`, fileContents);
163
+
164
+ // Check for the inlined source map
165
+ assert(
166
+ fileContents.includes(
167
+ '//# sourceMappingURL=data:application/json;charset=utf-8;base64,'
168
+ )
169
+ );
103
170
  logger.info('- Build succeeded 🚀');
104
- results.buildTestPassed = true;
171
+ preShutdownResults.buildTestPassed = true;
105
172
  }
106
173
 
107
174
  // Test that the express server is running on the expected port
108
175
  // and that it is serving the files as expected.
109
- function testExpressServer(results, logger) {
110
- http.get(
111
- {
112
- host: 'localhost',
113
- port,
114
- path: '/PhoneLines.js',
115
- },
116
- response => {
117
- if (response.statusCode !== 200) {
118
- throw Error('Error with express server');
119
- }
176
+ function testExpressServer(logger, expected, filename) {
177
+ axios
178
+ .get(`${host}:${VITE_DEFAULT_PORT}/${filename}`)
179
+ .then(response => {
180
+ assert.strictEqual(response.status, 200, 'Bundle status code');
120
181
  logger.info('- Express server connected and serving files 🚀');
121
- results.expressTestPassed = true;
122
- }
123
- );
182
+ preShutdownResults.expressStaticTestPassed = true;
183
+ })
184
+ .catch(logger.error);
185
+ axios
186
+ .get(`${host}:${VITE_DEFAULT_PORT}/extensions`)
187
+ .then(response => {
188
+ const { data, status } = response;
189
+ assert.strictEqual(status, 200, '/extensions status code');
190
+
191
+ // Top level response assertions
192
+ assert.strictEqual(data.websocket, expected.websocketUrl);
193
+ assert.strictEqual(data.version, EXTENSIONS_MESSAGE_VERSION);
194
+ assert(data.extensions);
195
+
196
+ // Extension level response assertions
197
+ assert.equal(data.extensions.length, 1, '/extensions body length');
198
+ const extension = data.extensions.pop();
199
+ assert(extension.manifest);
200
+ assert.strictEqual(
201
+ extension.appName,
202
+ expected.appName,
203
+ '/extensions appName'
204
+ );
205
+ assert.strictEqual(extension.title, expected.title, '/extensions title');
206
+ assert.strictEqual(
207
+ extension.callback,
208
+ expected.callback,
209
+ '/extensions callback'
210
+ );
211
+ logger.info('- Express serving extension data 🚀');
212
+ preShutdownResults.extensionsEndpointPassed = true;
213
+ })
214
+ .catch(logger.error);
124
215
  }
125
216
 
126
217
  // Test the the web socket server is running on the expected port and
127
218
  // that we are able to receive messages from it.
128
- function testWebSocketServer(results, logger) {
129
- const fileContents = fs
130
- .readFileSync(path.join('dist', 'PhoneLines.js'))
131
- .toString('base64');
132
-
133
- const ws = new WebSocket(`ws://localhost:${port}`);
219
+ function testWebSocketServer(logger, expected) {
220
+ const ws = new WebSocket(expected.websocketUrl);
134
221
  ws.on('message', messageBuffer => {
135
222
  const message = JSON.parse(messageBuffer.toString());
136
- assert(message.event === 'start' || message.event === 'update');
137
- assert(message.appName === 'example-app');
138
- assert.strictEqual(message.extension, 'Phone Lines');
139
- assert(message.callback, `data:text/javascript;base64,${fileContents}`);
140
- logger.info('- WebSocket server connected and sending messages 🚀');
141
- results.webSocketTestPassed = true;
223
+ console.debug('[WebSocket Message]:', message);
224
+ // Vite sends { type: 'connected'} as a greeting on connect
225
+ if (message.type === 'connected') {
226
+ return;
227
+ }
228
+
229
+ assert(['start', 'shutdown'].includes(message.event), 'Message Event');
230
+ assert.strictEqual(message.appName, expected.appName, 'Message appName');
231
+ assert.strictEqual(message.title, expected.title, 'Message title');
232
+ assert.strictEqual(message.version, WEBSOCKET_MESSAGE_VERSION);
233
+ logger.info(
234
+ `- WebSocket server connected and sending '${message.event}' messages 🚀`
235
+ );
236
+ if (message.event === 'start') {
237
+ preShutdownResults.webSocketTestPassed = true;
238
+ } else if (message.event === 'shutdown') {
239
+ postShutdownResults.websocketShutdownReceived = true;
240
+ }
142
241
  });
143
242
  }
144
243
 
244
+ function testPostShutdown(logger, filename) {
245
+ testExpressHasShutdown(filename);
246
+
247
+ const shutdownInterval = setInterval(shutdownCallback, 1000);
248
+ let shutdownCheckCount = 0;
249
+ function shutdownCallback() {
250
+ shutdownCheckCount += 1;
251
+ if (metPostShutdownConditions()) {
252
+ clearInterval(shutdownInterval);
253
+ logger.info('- Dev Server post-shutdown tests passed 🚀');
254
+ logger.info('Dev Server tests passed 🚀');
255
+ running = false;
256
+ } else if (shutdownCheckCount === 5) {
257
+ clearInterval(shutdownInterval);
258
+ console.log(postShutdownResults);
259
+ logger.error('Tests failed 😭');
260
+ }
261
+ }
262
+ }
263
+
264
+ function testExpressHasShutdown(filename) {
265
+ axios
266
+ .get(`${host}:${VITE_DEFAULT_PORT}/${filename}`)
267
+ .then(response => {
268
+ console.debug(response);
269
+ })
270
+ .catch(err => {
271
+ assert.strictEqual(
272
+ err.syscall,
273
+ 'connect',
274
+ 'The connect call should fail'
275
+ );
276
+ assert.strictEqual(
277
+ err.code,
278
+ 'ECONNREFUSED',
279
+ 'Express should refuse connection post shutdown'
280
+ );
281
+ postShutdownResults.expressServerShutdown = true;
282
+ });
283
+ }
284
+
145
285
  module.exports = {
146
286
  testDevServer,
147
287
  };
package/tests/utils.js ADDED
@@ -0,0 +1,76 @@
1
+ const path = require('path');
2
+ const { MANIFEST_FILE } = require('../constants');
3
+ const { getUrlSafeFileName } = require('../utils');
4
+ const assert = require('assert');
5
+ const { loadConfig } = require('../config');
6
+
7
+ function generateManifestOutputPair(entrypoint) {
8
+ const fileName = path.parse(entrypoint).name;
9
+ return [`${fileName}-${MANIFEST_FILE}`, getUrlSafeFileName(entrypoint)];
10
+ }
11
+
12
+ function verifyFileContents(file, contents) {
13
+ if (file.endsWith(MANIFEST_FILE)) {
14
+ const manifest = JSON.parse(contents);
15
+ assert(manifest.entry);
16
+ assert(manifest.modules);
17
+ assert(manifest.modules.internal);
18
+ manifest.modules.internal.forEach(mod => {
19
+ assert(mod.module);
20
+ assert(mod.renderedExports);
21
+ });
22
+ assert(manifest.modules.external);
23
+ manifest.modules.external.forEach(mod => {
24
+ assert(mod.module);
25
+ assert(mod.renderedExports);
26
+ });
27
+ assert(manifest.package);
28
+ assert(manifest.package.packages);
29
+ } else {
30
+ const stringsToSpotCheck = [
31
+ '.createRemoteReactComponent',
32
+ '.createElement',
33
+ 'hubspot.extend',
34
+ 'React',
35
+ 'RemoteUI',
36
+ ];
37
+ stringsToSpotCheck.forEach(stringToCheck => {
38
+ assert(
39
+ contents.includes(stringToCheck),
40
+ `File ${file} contents should contain: "${stringToCheck}"`
41
+ );
42
+ });
43
+ }
44
+ }
45
+
46
+ function generateSpec() {
47
+ const config = loadConfig();
48
+ const entrypoints = Object.keys(config);
49
+ if (entrypoints.length === 0) {
50
+ throw new Error(
51
+ 'Unable to determine testable entrypoints from config files'
52
+ );
53
+ }
54
+
55
+ const dev = entrypoints.map(entrypoint => {
56
+ const { data } = config[entrypoint];
57
+ return {
58
+ entrypoint,
59
+ expected: {
60
+ ...data,
61
+ },
62
+ };
63
+ });
64
+ return {
65
+ build: {
66
+ entrypoints,
67
+ },
68
+ dev,
69
+ };
70
+ }
71
+
72
+ module.exports = {
73
+ generateManifestOutputPair,
74
+ verifyFileContents,
75
+ generateSpec,
76
+ };