gristlabs_grist-core/test/nbrowser/AttachedCustomWidget.ts
Paul Fitzpatrick cc9a9ae8c5 (core) support for bundling custom widgets with the Grist app
Summary:
This adds support for bundling custom widgets with the Grist app, as follows:

 * Adds a new `widgets` component to plugins mechanism.
 * When a set of widgets is provided in a plugin, the html/js/css assets for those widgets are served on the existing untrusted user content port.
 * Any bundled `grist-plugin-api.js` will be served with the Grist app's own version of that file. It is important that bundled widgets not refer to https://docs.getgrist.com for the plugin js, since they must be capable of working offline.
 * The logic for configuring that port is updated a bit.
 * I removed the CustomAttachedView class in favor of applying settings of bundled custom widgets more directly, without modification on view.

Any Grist installation via docker will need an extra step now, since there is an extra port that needs exposing for full functionality. I did add a `GRIST_TRUST_PLUGINS` option for anyone who really doesn't want to do this, and would prefer to trust the plugins and have them served on the same port.

Actually making use of bundling will be another step. It'll be important to mesh it with our SaaS's use of APP_STATIC_URL for serving most static assets.

Design sketch: https://grist.quip.com/bJlWACWzr2R9/Bundled-custom-widgets

Test Plan: added a test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4069
2023-10-27 17:00:10 -04:00

124 lines
4.7 KiB
TypeScript

import {ICustomWidget} from "app/common/CustomWidget";
import {getAppRoot} from "app/server/lib/places";
import {assert, By, driver} from "mocha-webdriver";
import path from "path";
import * as gu from "test/nbrowser/gristUtils";
import {server, setupTestSuite} from "test/nbrowser/testUtils";
import {serveSomething} from "test/server/customUtil";
import {EnvironmentSnapshot} from "test/server/testUtils";
describe('AttachedCustomWidget', function () {
this.timeout(20000);
const cleanup = setupTestSuite();
let oldEnv: EnvironmentSnapshot;
// Valid manifest url.
const manifestEndpoint = '/manifest.json';
// Valid widget url.
const widgetEndpoint = '/widget';
// Create some widgets:
const widget1: ICustomWidget = {
widgetId: '@gristlabs/widget-calendar',
name: 'Calendar',
url: widgetEndpoint + '?name=Calendar',
};
let widgetServerUrl = '';
// Holds widgets manifest content.
let widgets: ICustomWidget[] = [];
// Switches widget manifest url
async function useManifest(url: string) {
await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : '');
await driver.executeAsyncScript(
(done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done()
);
}
async function buildWidgetServer(){
// Create simple widget server that serves manifest.json file, some widgets and some error pages.
const widgetServer = await serveSomething(app => {
app.get(widgetEndpoint, (req, res) =>
res
.header('Content-Type', 'text/html')
.send('<html><head><script src="/grist-plugin-api.js"></script></head><body>\n' +
(req.query.name || req.query.access) + // send back widget name from query string or access level
'</body>'+
"<script>grist.ready({requiredAccess: 'full', columns: [{name: 'Content', type: 'Text'}],"+
" onEditOptions(){}})</script>"+
'</html>\n')
.end()
);
app.get(manifestEndpoint, (_, res) =>
res
.header('Content-Type', 'application/json')
// prefix widget endpoint with server address
.json(widgets.map(widget => ({...widget, url: `${widgetServerUrl}${widget.url}`})))
.end()
);
app.get('/grist-plugin-api.js', (_, res) =>
res.sendFile(
'grist-plugin-api.js', {
root: path.resolve(getAppRoot(), "static")
}));
});
cleanup.addAfterAll(widgetServer.shutdown);
widgetServerUrl = widgetServer.url;
widgets = [widget1];
}
before(async function () {
await buildWidgetServer();
oldEnv = new EnvironmentSnapshot();
process.env.PERMITTED_CUSTOM_WIDGETS = "calendar";
await server.restart();
await useManifest(manifestEndpoint);
const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist');
});
after(async function () {
oldEnv.restore();
await server.restart();
});
it('should be able to attach Calendar Widget', async () => {
await gu.openAddWidgetToPage();
const calendarElement = await driver.findContent('.test-wselect-type', /Calendar/);
assert.exists(calendarElement, 'Calendar widget is not found in the list of widgets');
});
it('should not ask for permission', async () => {
await gu.addNewSection(/Calendar/, /Table1/, {selectBy: /TABLE1/});
await gu.getSection('TABLE1 Calendar').click();
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-pagewidget').click();
await gu.waitForServer();
// Check if widget config panel is here
await driver.findWait('.test-config-container', 2000);
const widgetOptions = await driver.findWait('.test-config-widget-open-configuration', 2000);
const widgetMapping = await driver.find('.test-config-widget-mapping-for-Content');
const widgetSelection = await driver.findElements(By.css('.test-config-widget-select'));
const widgetPermission = await driver.findElements(By.css('.test-wselect-permission'));
assert.isEmpty(widgetSelection, 'Widget selection is not expected to be present');
assert.isEmpty(widgetPermission, 'Widget permission is not expected to be present');
assert.exists(widgetOptions, 'Widget options is expected to be present');
assert.exists(widgetMapping, 'Widget mapping is expected to be present');
});
it('should display the content of the widget', async () => {
await gu.getSection('TABLE1 Calendar').click();
try {
await driver.switchTo().frame(await driver.findWait('.custom_view', 1000));
const editor = await driver.findContentWait('body', "Calendar", 1000);
assert.exists(editor);
} finally {
await driver.switchTo().defaultContent();
}
});
});