(core) Adding UI for timing API

Summary:
Adding new buttons to control the `timing` API and a way to view the results
using virtual table features.

Test Plan: Added new

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4252
This commit is contained in:
Jarosław Sadziński
2024-05-21 18:27:06 +02:00
parent 60423edc17
commit a6ffa6096a
29 changed files with 858 additions and 144 deletions

View File

@@ -1,4 +1,4 @@
import { Sort } from 'app/common/SortSpec';
import { Sort, VirtualId } from 'app/common/SortSpec';
import { assert } from 'chai';
const { flipSort: flipColDirection, parseSortColRefs, reorderSortRefs } = Sort;
@@ -76,11 +76,22 @@ describe('sortUtil', function () {
assert.deepEqual(Sort.setColDirection('2:emptyLast', Sort.DESC), '-2:emptyLast');
});
it('should create column expressions for virtual ids', function () {
assert.deepEqual(Sort.setColDirection(VirtualId('test'), Sort.DESC), `-${VirtualId('test')}`);
assert.deepEqual(Sort.setColDirection(VirtualId('test'), Sort.ASC), VirtualId('test'));
assert.deepEqual(Sort.setColDirection(`-${VirtualId('test')}`, Sort.ASC), VirtualId('test'));
assert.deepEqual(Sort.setColDirection(`-${VirtualId('test')}`, Sort.DESC), `-${VirtualId('test')}`);
});
const empty = { emptyLast: false, orderByChoice: false, naturalSort: false };
it('should parse details', function () {
assert.deepEqual(Sort.specToDetails(2), { colRef: 2, direction: Sort.ASC });
assert.deepEqual(Sort.specToDetails(-2), { colRef: 2, direction: Sort.DESC });
assert.deepEqual(Sort.specToDetails(VirtualId('test')), { colRef: VirtualId('test'), direction: Sort.ASC });
assert.deepEqual(Sort.specToDetails(`-${VirtualId('test')}`), { colRef: VirtualId('test'), direction: Sort.DESC });
assert.deepEqual(Sort.specToDetails('-2:emptyLast'),
{ ...empty, colRef: 2, direction: Sort.DESC, emptyLast: true });
assert.deepEqual(Sort.specToDetails('-2:emptyLast;orderByChoice'), {
@@ -93,6 +104,10 @@ describe('sortUtil', function () {
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC }), 2);
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC }), -2);
assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId('test'), direction: Sort.ASC }), VirtualId('test'));
assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId('test'), direction: Sort.DESC }), `-${VirtualId('test')}`);
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC, emptyLast: true }), '2:emptyLast');
assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC, emptyLast: true }), '-2:emptyLast');
assert.deepEqual(

View File

@@ -158,7 +158,7 @@ describe('CopyPaste', function() {
// Change the document locale
await gu.openDocumentSettings();
await driver.findWait('.test-locale-autocomplete', 500).click();
await driver.findWait('.test-settings-locale-autocomplete', 500).click();
await driver.sendKeys("Germany", Key.ENTER);
await gu.waitForServer();
await driver.navigate().back();

View File

@@ -77,10 +77,10 @@ describe('NumericEditor', function() {
// Set locale for the document.
await gu.openDocumentSettings();
await driver.findWait('.test-locale-autocomplete', 500).click();
await driver.findWait('.test-settings-locale-autocomplete', 500).click();
await driver.sendKeys(options.locale, Key.ENTER);
await gu.waitForServer();
assert.equal(await driver.find('.test-locale-autocomplete input').value(), options.locale);
assert.equal(await driver.find('.test-settings-locale-autocomplete input').value(), options.locale);
await gu.openPage('Table1');
});

196
test/nbrowser/Timing.ts Normal file
View File

@@ -0,0 +1,196 @@
import { DocAPI, UserAPI } from "app/common/UserAPI";
import difference from 'lodash/difference';
import { assert, driver } from "mocha-webdriver";
import * as gu from "test/nbrowser/gristUtils";
import { setupTestSuite } from "test/nbrowser/testUtils";
describe("Timing", function () {
this.timeout(20000);
const cleanup = setupTestSuite();
let docApi: DocAPI;
let userApi: UserAPI;
let docId: string;
let session: gu.Session;
before(async () => {
session = await gu.session().teamSite.login();
docId = await session.tempNewDoc(cleanup);
userApi = session.createHomeApi();
docApi = userApi.getDocAPI(docId);
});
async function assertOn() {
await gu.waitToPass(async () => {
assert.equal(await timingText.text(), "Timing is on...");
});
assert.isTrue(await stopTiming.visible());
assert.isFalse(await startTiming.present());
}
async function assertOff() {
await gu.waitToPass(async () => {
assert.equal(await timingText.text(), "Find slow formulas");
});
assert.isTrue(await startTiming.visible());
assert.isFalse(await stopTiming.present());
}
it("should allow to start session", async function () {
await gu.openDocumentSettings();
// Make sure we see the timing button.
await assertOff();
// Start timing.
await startTiming.click();
// Wait for modal.
await modal.wait();
// We have two options.
assert.isTrue(await optionStart.visible());
assert.isTrue(await optionReload.visible());
// Start is selected by default.
assert.isTrue(await optionStart.checked());
assert.isFalse(await optionReload.checked());
await modalConfirm.click();
await assertOn();
});
it('should reflect that in the API', async function() {
assert.equal(await docApi.timing().then(x => x.status), 'active');
});
it('should stop session from outside', async function() {
await docApi.stopTiming();
await assertOff();
});
it('should start session from API', async function() {
await docApi.startTiming();
// Add new record through the API (to trigger formula calculations).
await userApi.applyUserActions(docId, [
['AddRecord', 'Table1', null, {}]
]);
});
it('should show result and stop session', async function() {
// The stop button is actually stop and show results, and it will open new window in.
const myTab = await gu.myTab();
const tabs = await driver.getAllWindowHandles();
await stopTiming.click();
// Now new tab will be opened, and the timings will be stopped.
await gu.waitToPass(async () => {
assert.equal(await docApi.timing().then(x => x.status), 'disabled');
});
// Find the new tab.
const newTab = difference(await driver.getAllWindowHandles(), tabs)[0];
assert.isDefined(newTab);
await driver.switchTo().window(newTab);
// Sanity check that we see some results.
assert.isTrue(await driver.findContentWait('div', 'Formula timer', 1000).isDisplayed());
await gu.waitToPass(async () => {
assert.equal(await gu.getCell(0, 1).getText(), 'Table1');
});
// Switch back to the original tab.
await myTab.open();
// Make sure controls are back to the initial state.
await assertOff();
// Close the new tab.
await driver.switchTo().window(newTab);
await driver.close();
await myTab.open();
});
it("should allow to time the document load", async function () {
await assertOff();
await startTiming.click();
await modal.wait();
// Check that cancel works.
await modalCancel.click();
assert.isFalse(await modal.present());
await assertOff();
// Open modal once again but this time select reload.
await startTiming.click();
await optionReload.click();
assert.isTrue(await optionReload.checked());
await modalConfirm.click();
// We will see spinner.
await gu.waitToPass(async () => {
await driver.findContentWait('div', 'Loading timing data.', 1000);
});
// We land on the timing page in the same tab.
await gu.waitToPass(async () => {
assert.isTrue(await driver.findContentWait('div', 'Formula timer', 1000).isDisplayed());
assert.equal(await gu.getCell(0, 1).getText(), 'Table1');
});
// Refreshing this tab will move us to the settings page.
await driver.navigate().refresh();
await gu.waitForUrl('/settings');
});
});
const element = (testId: string) => ({
element() {
return driver.find(testId);
},
async wait() {
await driver.findWait(testId, 1000);
},
async visible() {
return await this.element().isDisplayed();
},
async present() {
return await this.element().isPresent();
}
});
const label = (testId: string) => ({
...element(testId),
async text() {
return this.element().getText();
},
});
const button = (testId: string) => ({
...element(testId),
async click() {
await gu.scrollIntoView(this.element());
await this.element().click();
},
});
const option = (testId: string) => ({
...button(testId),
async checked() {
return 'true' === await this.element().findClosest("label").find("input[type='checkbox']").getAttribute('checked');
}
});
const startTiming = button(".test-settings-timing-start");
const stopTiming = button(".test-settings-timing-stop");
const timingText = label(".test-settings-timing-desc");
const modal = element(".test-settings-timing-modal");
const optionStart = option('.test-settings-timing-modal-option-adhoc');
const optionReload = option('.test-settings-timing-modal-option-reload');
const modalConfirm = button('.test-settings-timing-modal-confirm');
const modalCancel = button('.test-settings-timing-modal-cancel');

View File

@@ -4261,7 +4261,7 @@ function testDocApi() {
await notFoundCalled.waitAndReset();
// But the working endpoint won't be called more then once.
assert.isFalse(successCalled.called());
successCalled.assertNotCalled();
// Trigger second event.
await doc.addRows("Table1", {
@@ -4273,13 +4273,13 @@ function testDocApi() {
assert.deepEqual(firstRow, 1);
// But the working endpoint won't be called till we reset the queue.
assert.isFalse(successCalled.called());
successCalled.assertNotCalled();
// Now reset the queue.
await clearQueue(docId);
assert.isFalse(successCalled.called());
assert.isFalse(notFoundCalled.called());
successCalled.assertNotCalled();
notFoundCalled.assertNotCalled();
// Prepare for new calls.
successCalled.reset();
@@ -4297,7 +4297,7 @@ function testDocApi() {
// And the situation will be the same, the working endpoint won't be called till we reset the queue, but
// the error endpoint will be called with the third row multiple times.
await notFoundCalled.waitAndReset();
assert.isFalse(successCalled.called());
successCalled.assertNotCalled();
// Cleanup everything, we will now test request timeouts.
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);
@@ -4319,7 +4319,7 @@ function testDocApi() {
// Long will be started immediately.
await longStarted.waitAndReset();
// But it won't be finished.
assert.isFalse(longFinished.called());
longFinished.assertNotCalled();
// It will be aborted.
controller.abort();
assert.deepEqual(await longFinished.waitAndReset(), [408, 4]);
@@ -4333,7 +4333,7 @@ function testDocApi() {
// abort it till the end of this test.
assert.deepEqual(await successCalled.waitAndReset(), 5);
assert.deepEqual(await longStarted.waitAndReset(), 5);
assert.isFalse(longFinished.called());
longFinished.assertNotCalled();
// Remember this controller for cleanup.
const controller5 = controller;
@@ -4343,8 +4343,8 @@ function testDocApi() {
B: [true],
});
// We are now completely stuck on the 5th row webhook.
assert.isFalse(successCalled.called());
assert.isFalse(longFinished.called());
successCalled.assertNotCalled();
longFinished.assertNotCalled();
// Clear the queue, it will free webhooks requests, but it won't cancel long handler on the external server
// so it is still waiting.
assert.isTrue((await axios.delete(
@@ -4356,8 +4356,8 @@ function testDocApi() {
assert.deepEqual(await longFinished.waitAndReset(), [408, 5]);
// We won't be called for the 6th row at all, as it was stuck and the queue was purged.
assert.isFalse(successCalled.called());
assert.isFalse(longStarted.called());
successCalled.assertNotCalled();
longStarted.assertNotCalled();
// Trigger next event.
await doc.addRows("Table1", {
@@ -4368,7 +4368,7 @@ function testDocApi() {
assert.deepEqual(await successCalled.waitAndReset(), 7);
assert.deepEqual(await longStarted.waitAndReset(), 7);
// But we are stuck again.
assert.isFalse(longFinished.called());
longFinished.assertNotCalled();
// And we can abort current request from 7th row (6th row was skipped).
controller.abort();
assert.deepEqual(await longFinished.waitAndReset(), [408, 7]);
@@ -4411,7 +4411,7 @@ function testDocApi() {
controller.abort();
await longFinished.waitAndReset();
// The second one is not called.
assert.isFalse(successCalled.called());
successCalled.assertNotCalled();
// Triggering next event, we will get only calls to the probe (first webhook).
await doc.addRows("Table1", {
A: [2],
@@ -4438,14 +4438,12 @@ function testDocApi() {
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.assertNotCalled();
successCalled.reset();
};
const assertSuccessCalled = async () => {
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
};
@@ -4460,8 +4458,6 @@ function testDocApi() {
B: [true],
C: ['c1']
});
await delay(100);
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
await modifyColumn({ C: 'c2' });
await assertSuccessNotCalled();

View File

@@ -232,7 +232,8 @@ describe('DocApi2', function() {
// And check that we are still on.
resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);
assert.equal(resp.status, 200, JSON.stringify(resp.data));
assert.deepEqual(resp.data, {status: 'active', timing: []});
assert.equal(resp.data.status, 'active');
assert.isNotEmpty(resp.data.timing);
});
});
});

View File

@@ -306,7 +306,7 @@ describe('Webhooks-Proxy', function () {
await notFoundCalled.waitAndReset();
// But the working endpoint won't be called more then once.
assert.isFalse(successCalled.called());
successCalled.assertNotCalled();
//Cleanup all
await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);

View File

@@ -1,44 +1,58 @@
import {delay} from "bluebird";
import {delay} from 'bluebird';
import {assert} from 'chai';
/**
* Helper that creates a promise that can be resolved from outside.
*
* @example
* const methodCalled = signal();
* setTimeout(() => methodCalled.emit(), 1000);
* methodCalled.assertNotCalled(); // won't throw as the method hasn't been called yet
* await methodCalled.wait(); // will wait for the method to be called
* await methodCalled.wait(); // can be called multiple times
* methodCalled.reset(); // resets the signal (so that it can be awaited again)
* setTimeout(() => methodCalled.emit(), 3000);
* await methodCalled.wait(); // will fail, as we wait only 2 seconds
*/
export function signal() {
let resolve: null | ((data: any) => void) = null;
let promise: null | Promise<any> = null;
let called = false;
return {
emit(data: any) {
if (!resolve) {
throw new Error("signal.emit() called before signal.reset()");
}
called = true;
resolve(data);
},
async wait() {
if (!promise) {
throw new Error("signal.wait() called before signal.reset()");
}
const proms = Promise.race([promise, delay(2000).then(() => {
throw new Error("signal.wait() timed out");
})]);
return await proms;
},
async waitAndReset() {
try {
return await this.wait();
} finally {
this.reset();
}
},
called() {
return called;
},
reset() {
called = false;
promise = new Promise((res) => {
resolve = res;
});
}
};
let resolve: null | ((data: any) => void) = null;
let promise: null | Promise<any> = null;
let called = false;
return {
emit(data: any) {
if (!resolve) {
throw new Error("signal.emit() called before signal.reset()");
}
called = true;
resolve(data);
},
async wait() {
if (!promise) {
throw new Error("signal.wait() called before signal.reset()");
}
const proms = Promise.race([
promise,
delay(2000).then(() => {
throw new Error("signal.wait() timed out");
}),
]);
return await proms;
},
async waitAndReset() {
try {
return await this.wait();
} finally {
this.reset();
}
},
assertNotCalled() {
assert.isFalse(called);
},
reset() {
called = false;
promise = new Promise((res) => {
resolve = res;
});
},
};
}