(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick
2024-04-15 09:24:09 -04:00
46 changed files with 2829 additions and 289 deletions

View File

@@ -46,7 +46,7 @@ describe('SafeBrowser', function() {
browserProcesses = [];
sandbox.stub(SafeBrowser, 'createWorker').callsFake(createProcess);
sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess);
sandbox.stub(SafeBrowser, 'createView').callsFake(createProcess as any);
sandbox.stub(PluginInstance.prototype, 'getRenderTarget').returns(noop);
disposeSpy = sandbox.spy(Disposable.prototype, 'dispose');
});

View File

@@ -153,9 +153,8 @@ describe('dispose', function() {
assert.equal(baz.dispose.callCount, 1);
assert(baz.dispose.calledBefore(bar.dispose));
const name = consoleErrors[0][1]; // may be Foo, or minified.
assert(name === 'Foo' || name === 'o'); // this may not be reliable,
// just what I happen to see.
const name = consoleErrors[0][1];
assert(name === Foo.name);
assert.deepEqual(consoleErrors[0], ['Error constructing %s:', name, 'Error: test-error1']);
assert.deepEqual(consoleErrors[1], ['Error constructing %s:', name, 'Error: test-error2']);
assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']);

View File

@@ -0,0 +1,56 @@
// Test for DocWorkerMap.ts
import { DocWorkerMap } from 'app/gen-server/lib/DocWorkerMap';
import { DocWorkerInfo } from 'app/server/lib/DocWorkerMap';
import {expect} from 'chai';
import sinon from 'sinon';
describe('DocWorkerMap', () => {
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
});
describe('isWorkerRegistered', () => {
const baseWorkerInfo: DocWorkerInfo = {
id: 'workerId',
internalUrl: 'internalUrl',
publicUrl: 'publicUrl',
group: undefined
};
[
{
itMsg: 'should check if worker is registered',
sisMemberAsyncResolves: 1,
expectedResult: true,
expectedKey: 'workers-available-default'
},
{
itMsg: 'should check if worker is registered in a certain group',
sisMemberAsyncResolves: 1,
group: 'dummygroup',
expectedResult: true,
expectedKey: 'workers-available-dummygroup'
},
{
itMsg: 'should return false if worker is not registered',
sisMemberAsyncResolves: 0,
expectedResult: false,
expectedKey: 'workers-available-default'
}
].forEach(ctx => {
it(ctx.itMsg, async () => {
const sismemberAsyncStub = sinon.stub().resolves(ctx.sisMemberAsyncResolves);
const stubDocWorkerMap = {
_client: { sismemberAsync: sismemberAsyncStub }
};
const result = await DocWorkerMap.prototype.isWorkerRegistered.call(
stubDocWorkerMap, {...baseWorkerInfo, group: ctx.group }
);
expect(result).to.equal(ctx.expectedResult);
expect(sismemberAsyncStub.calledOnceWith(ctx.expectedKey, baseWorkerInfo.id)).to.equal(true);
});
});
});
});

View File

@@ -8,6 +8,18 @@ import fs from "fs";
import os from "os";
import path from 'path';
// We only support those formats for now:
// en.client.json
// en_US.client.json
// en_US.server.json
// zh_Hant.client.json
// {lang code (+ maybe with underscore and country code}.{namespace}.json
//
// Only this format was tested and is known to work.
const VALID_LOCALE_FORMAT = /^[a-z]{2,}(_\w+)?\.(\w+)\.json$/;
describe("Localization", function() {
this.timeout(60000);
setupTestSuite();
@@ -40,20 +52,20 @@ describe("Localization", function() {
const langs: Set<string> = new Set();
const namespaces: Set<string> = new Set();
for (const file of fs.readdirSync(localeDirectory)) {
if (file.endsWith(".json")) {
const langRaw = file.split('.')[0];
const lang = langRaw?.replace(/_/g, '-');
const ns = file.split('.')[1];
const clientFile = path.join(localeDirectory,
`${langRaw}.client.json`);
const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' });
if (!clientText.includes('Translators: please translate this only when')) {
// Translation not ready if this key is not present.
continue;
}
langs.add(lang);
namespaces.add(ns);
// Make sure we see only valid files.
assert.match(file, VALID_LOCALE_FORMAT);
const langRaw = file.split('.')[0];
const lang = langRaw?.replace(/_/g, '-');
const ns = file.split('.')[1];
const clientFile = path.join(localeDirectory,
`${langRaw}.client.json`);
const clientText = fs.readFileSync(clientFile, { encoding: 'utf8' });
if (!clientText.includes('Translators: please translate this only when')) {
// Translation not ready if this key is not present.
continue;
}
langs.add(lang);
namespaces.add(ns);
}
assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort());
assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort());
@@ -90,6 +102,8 @@ describe("Localization", function() {
const enResponse = await (await fetch(homeUrl)).text();
const uzResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "uz-UZ,uz;q=1"}})).text();
const ptResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "pt-PR,pt;q=1"}})).text();
// We have file with nb_NO code, but still this should be preloaded.
const noResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "nb-NO,nb;q=1"}})).text();
function present(response: string, ...langs: string[]) {
for (const lang of langs) {
@@ -107,6 +121,7 @@ describe("Localization", function() {
present(enResponse, "en");
present(uzResponse, "en");
present(ptResponse, "en");
present(noResponse, "en");
// Other locales are not preloaded for English.
notPresent(enResponse, "uz", "un-UZ", "en-US");
@@ -117,6 +132,9 @@ describe("Localization", function() {
notPresent(uzResponse, "uz-UZ");
notPresent(ptResponse, "pt-PR", "uz", "en-US");
// For no-NO we have nb_NO file.
present(noResponse, "nb_NO");
});
it("loads correct languages from file system", async function() {

View File

@@ -34,6 +34,7 @@ describe('WebhookOverflow', function () {
enabled: true,
name: 'test webhook',
tableId: 'Table2',
watchedColIds: []
};
await docApi.addWebhook(webhookDetails);
await docApi.addWebhook(webhookDetails);

View File

@@ -55,6 +55,7 @@ describe('WebhookPage', function () {
'URL',
'Table',
'Ready Column',
'Filter for changes in these columns (semicolon-separated ids)',
'Webhook Id',
'Enabled',
'Status',
@@ -80,15 +81,17 @@ describe('WebhookPage', function () {
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Webhook Id'), id);
});
// Now other fields like name and memo are persisted.
// Now other fields like name, memo and watchColIds are persisted.
await setField(1, 'Name', 'Test Webhook');
await setField(1, 'Memo', 'Test Memo');
await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B');
await gu.waitForServer();
await driver.navigate().refresh();
await waitForWebhookPage();
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Name'), 'Test Webhook');
assert.equal(await getField(1, 'Memo'), 'Test Memo');
assert.equal(await getField(1, 'Filter for changes in these columns (semicolon-separated ids)'), 'A;B');
});
// Make sure the webhook is actually working.
await docApi.addRows('Table1', {A: ['zig'], B: ['zag']});

View File

@@ -67,6 +67,9 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
this._starts++;
const workerIdText = process.env.MOCHA_WORKER_ID || '0';
if (reset) {
// Make sure this test server doesn't keep using the DB that's about to disappear.
await this.closeDatabase();
if (process.env.TESTDIR) {
this.testDir = path.join(process.env.TESTDIR, workerIdText);
} else {

View File

@@ -0,0 +1,62 @@
import { assert, driver, Key } from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import { cleanupExtraWindows, setupTestSuite } from 'test/nbrowser/testUtils';
describe('Create Team Site', function () {
this.timeout(20000);
cleanupExtraWindows();
const cleanup = setupTestSuite();
before(async function () {
const session = await gu.session().teamSite.login();
await session.tempNewDoc(cleanup);
});
async function openCreateTeamModal() {
await driver.findWait('.test-dm-org', 500).click();
assert.equal(await driver.find('.test-site-switcher-create-new-site').isPresent(), true);
await driver.find('.test-site-switcher-create-new-site').click();
}
async function fillCreateTeamModalInputs(name: string, domain: string) {
await driver.findWait('.test-create-team-name', 500).click();
await gu.sendKeys(name);
await gu.sendKeys(Key.TAB);
await gu.sendKeys(domain);
}
async function goToNewTeamSite() {
await driver.findWait('.test-create-team-confirmation-link', 500).click();
}
async function getTeamSiteName() {
return await driver.findWait('.test-dm-orgname', 500).getText();
}
it('should work using the createTeamModal', async () => {
assert.equal(await driver.find('.test-dm-org').isPresent(), true);
const teamSiteName = await getTeamSiteName();
assert.equal(teamSiteName, 'Test Grist');
await openCreateTeamModal();
assert.equal(await driver.find('.test-create-team-creation-title').isPresent(), true);
await fillCreateTeamModalInputs("Test Create Team Site", "testteamsite");
await gu.sendKeys(Key.ENTER);
assert.equal(await driver.findWait('.test-create-team-confirmation', 500).isPresent(), true);
await goToNewTeamSite();
const newTeamSiteName = await getTeamSiteName();
assert.equal(newTeamSiteName, 'Test Create Team Site');
});
it('should work only with unique domain', async () => {
await openCreateTeamModal();
await fillCreateTeamModalInputs("Test Create Team Site 1", "same-domain");
await gu.sendKeys(Key.ENTER);
await goToNewTeamSite();
await openCreateTeamModal();
await fillCreateTeamModalInputs("Test Create Team Site 2", "same-domain");
await gu.sendKeys(Key.ENTER);
const errorMessage = await driver.findWait('.test-notifier-toast-wrapper ', 500).getText();
assert.include(errorMessage, 'Domain already in use');
});
});

View File

@@ -402,20 +402,19 @@ describe('Comm', function() {
// Intercept the call to _onClose to know when it occurs, since we are trying to hit a
// situation where 'close' and 'failedSend' events happen in either order.
const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(async function(this: Client) {
if (!options.closeHappensFirst) { await delay(10); }
const stubOnClose: any = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(function(this: Client) {
eventsSeen.push('close');
return (stubOnClose as any).wrappedMethod.apply(this, arguments);
return stubOnClose.wrappedMethod.apply(this, arguments);
});
// Intercept calls to client.sendMessage(), to know when it fails, and possibly to delay the
// failures to hit a particular order in which 'close' and 'failedSend' events are seen by
// Client.ts. This is the only reliable way I found to reproduce this order of events.
const stubSendToWebsocket = sandbox.stub(Client.prototype as any, '_sendToWebsocket')
const stubSendToWebsocket: any = sandbox.stub(Client.prototype as any, '_sendToWebsocket')
.callsFake(async function(this: Client) {
try {
return await (stubSendToWebsocket as any).wrappedMethod.apply(this, arguments);
return await stubSendToWebsocket.wrappedMethod.apply(this, arguments);
} catch (err) {
if (options.closeHappensFirst) { await delay(100); }
eventsSeen.push('failedSend');

View File

@@ -3347,13 +3347,21 @@ function testDocApi() {
});
describe('webhooks related endpoints', async function () {
/*
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe
*/
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {
const resp = await axios.post(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,
requestBody, chimpy
const serving: Serving = await serveSomething(app => {
app.use(express.json());
app.post('/200', ({body}, res) => {
res.sendStatus(200);
res.end();
});
}, webhooksTestPort);
/*
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe
*/
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {
const resp = await axios.post(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,
requestBody, chimpy
);
assert.equal(resp.status, status);
for (const error of errors) {
@@ -3430,7 +3438,15 @@ function testDocApi() {
await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]},
400, /tableId is missing/);
await postWebhookCheck({}, 400, /webhooks is missing/);
await postWebhookCheck({
webhooks: [{
fields: {
tableId: "Table1", eventTypes: ["update"], watchedColIds: ["notExisting"],
url: `${serving.url}/200`
}
}]
},
403, /Column not found notExisting/);
});
@@ -3855,6 +3871,7 @@ function testDocApi() {
tableId?: string,
isReadyColumn?: string | null,
eventTypes?: string[]
watchedColIds?: string[],
}) {
// Subscribe helper that returns a method to unsubscribe.
const data = await subscribe(endpoint, docId, options);
@@ -3872,6 +3889,7 @@ function testDocApi() {
tableId?: string,
isReadyColumn?: string|null,
eventTypes?: string[],
watchedColIds?: string[],
name?: string,
memo?: string,
enabled?: boolean,
@@ -3883,7 +3901,7 @@ function testDocApi() {
eventTypes: options?.eventTypes ?? ['add', 'update'],
url: `${serving.url}/${endpoint}`,
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn,
...pick(options, 'name', 'memo', 'enabled'),
...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'),
}, chimpy
);
assert.equal(status, 200);
@@ -4407,6 +4425,72 @@ function testDocApi() {
await webhook1();
});
it("should call to a webhook only when columns updated are in watchedColIds if not empty", async () => { // eslint-disable-line max-len
// Create a test document.
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
const docId = await userApi.newDoc({ name: 'testdoc5' }, ws1);
const doc = userApi.getDocAPI(docId);
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['ModifyColumn', 'Table1', 'B', { type: 'Bool' }],
], chimpy);
const modifyColumn = async (newValues: { [key: string]: any; } ) => {
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['UpdateRecord', 'Table1', newRowIds[0], newValues],
], chimpy);
await delay(100);
};
const assertSuccessNotCalled = async () => {
assert.isFalse(successCalled.called());
successCalled.reset();
};
const assertSuccessCalled = async () => {
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
};
// Webhook with only one watchedColId.
const webhook1 = await autoSubscribe('200', docId, {
watchedColIds: ['A'], eventTypes: ['add', 'update']
});
successCalled.reset();
// Create record, that will call the webhook.
const newRowIds = await doc.addRows("Table1", {
A: [2],
B: [true],
C: ['c1']
});
await delay(100);
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
await modifyColumn({ C: 'c2' });
await assertSuccessNotCalled();
await modifyColumn({ A: 19 });
await assertSuccessCalled();
await webhook1(); // Unsubscribe.
// Webhook with multiple watchedColIds
const webhook2 = await autoSubscribe('200', docId, {
watchedColIds: ['A', 'B'], eventTypes: ['update']
});
successCalled.reset();
await modifyColumn({ C: 'c3' });
await assertSuccessNotCalled();
await modifyColumn({ A: 20 });
await assertSuccessCalled();
await webhook2();
// Check that empty string in watchedColIds are ignored
const webhook3 = await autoSubscribe('200', docId, {
watchedColIds: ['A', ""], eventTypes: ['update']
});
await modifyColumn({ C: 'c4' });
await assertSuccessNotCalled();
await modifyColumn({ A: 21 });
await assertSuccessCalled();
await webhook3();
});
it("should return statistics", async () => {
await clearQueue(docId);
// Read stats, it should be empty.
@@ -4427,6 +4511,7 @@ function testDocApi() {
tableId: 'Table1',
name: '',
memo: '',
watchedColIds: [],
}, usage : {
status: 'idle',
numWaiting: 0,
@@ -4444,6 +4529,7 @@ function testDocApi() {
tableId: 'Table1',
name: '',
memo: '',
watchedColIds: [],
}, usage : {
status: 'idle',
numWaiting: 0,
@@ -4775,42 +4861,53 @@ function testDocApi() {
describe('webhook update', function () {
it('should work correctly', async function () {
async function check(fields: any, status: number, error?: RegExp | string,
expectedFieldsCallback?: (fields: any) => any) {
let savedTableId = 'Table1';
const origFields = {
tableId: 'Table1',
eventTypes: ['add'],
isReadyColumn: 'B',
name: 'My Webhook',
memo: 'Sync store',
watchedColIds: ['A']
};
// subscribe
const webhook = await subscribe('foo', docId, origFields);
const {data} = await axios.post(
`${serverUrl}/api/docs/${docId}/webhooks`,
{
webhooks: [{
fields: {
...origFields,
url: `${serving.url}/foo`
}
}]
}, chimpy
);
const webhooks = data;
const expectedFields = {
url: `${serving.url}/foo`,
unsubscribeKey: webhook.unsubscribeKey,
eventTypes: ['add'],
isReadyColumn: 'B',
tableId: 'Table1',
enabled: true,
name: 'My Webhook',
memo: 'Sync store',
watchedColIds: ['A'],
};
let stats = await readStats(docId);
assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats));
assert.equal(stats[0].id, webhook.webhookId);
assert.deepEqual(stats[0].fields, expectedFields);
assert.equal(stats[0].id, webhooks.webhooks[0].id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields);
// update
const resp = await axios.patch(
`${serverUrl}/api/docs/${docId}/webhooks/${webhook.webhookId}`, fields, chimpy
`${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, fields, chimpy
);
// check resp
@@ -4818,14 +4915,13 @@ function testDocApi() {
if (resp.status === 200) {
stats = await readStats(docId);
assert.equal(stats.length, 1);
assert.equal(stats[0].id, webhook.webhookId);
assert.equal(stats[0].id, webhooks.webhooks[0].id);
if (expectedFieldsCallback) {
expectedFieldsCallback(expectedFields);
}
assert.deepEqual(stats[0].fields, {...expectedFields, ...fields});
if (fields.tableId) {
savedTableId = fields.tableId;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields });
} else {
if (error instanceof RegExp) {
assert.match(resp.data.details?.userError || resp.data.error, error);
@@ -4835,7 +4931,9 @@ function testDocApi() {
}
// finally unsubscribe
const unsubscribeResp = await unsubscribe(docId, webhook, savedTableId);
const unsubscribeResp = await axios.delete(
`${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, chimpy
);
assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status'])));
stats = await readStats(docId);
assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats));
@@ -4846,11 +4944,13 @@ function testDocApi() {
await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https
// changing table without changing the ready column should reset the latter
await check({tableId: 'Table2'}, 200, '', expectedFields => expectedFields.isReadyColumn = null);
await check({tableId: 'Table2'}, 200, '', expectedFields => {
expectedFields.isReadyColumn = null;
expectedFields.watchedColIds = [];
});
await check({tableId: 'Santa'}, 404, `Table not found "Santa"`);
await check({tableId: 'Table2', isReadyColumn: 'Foo'}, 200);
await check({tableId: 'Table2', isReadyColumn: 'Foo', watchedColIds: []}, 200);
await check({eventTypes: ['add', 'update']}, 200);
await check({eventTypes: []}, 400, "eventTypes must be a non-empty array");

View File

@@ -48,7 +48,7 @@ describe("MinIOExternalStorage", function () {
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
const result = await extStorage.versions(key);
assert.deepEqual(result, []);
@@ -74,7 +74,7 @@ describe("MinIOExternalStorage", function () {
]);
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when
const result = await extStorage.versions(key);
// then
@@ -107,7 +107,7 @@ describe("MinIOExternalStorage", function () {
let {fakeStream} = makeFakeStream(objectsFromS3);
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when
const result = await extStorage.versions(key);
@@ -142,10 +142,10 @@ describe("MinIOExternalStorage", function () {
const fakeStream = new stream.Readable({objectMode: true});
const error = new Error("dummy-error");
sandbox.stub(fakeStream, "_read")
.returns(fakeStream)
.returns(fakeStream as any)
.callsFake(() => fakeStream.emit('error', error));
s3.listObjects.returns(fakeStream);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3);
const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);
// when
const result = extStorage.versions(key);
@@ -154,4 +154,4 @@ describe("MinIOExternalStorage", function () {
return assert.isRejected(result, error);
});
});
});
});