mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add support for auto-copying docs on signup
Summary: The new "copyDoc" query parameter on the login page sets a short-lived cookie, which is then read when welcoming a new user to copy that document to their Home workspace, and redirect to it. Currently, only templates and bare forks set this parameter. A new API endpoint for copying a document to a workspace was also added. Test Plan: Browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3992
This commit is contained in:
@@ -152,7 +152,7 @@ describe('CellColor', function() {
|
||||
const forkId = await gu.getCurrentUrlId();
|
||||
|
||||
// Compare with the original
|
||||
await mainSession.loadDoc(`/doc/${doc}?compare=${forkId}`);
|
||||
await mainSession.loadDoc(`/doc/${doc}?compare=${forkId}`, {skipAlert: true});
|
||||
|
||||
// check that colors for diffing cells are ok
|
||||
cell = gu.getCell('A', 1).find('.field_clip');
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('DocTutorial', function () {
|
||||
});
|
||||
|
||||
it('redirects user to log in', async function() {
|
||||
await viewerSession.loadDoc(`/doc/${doc.id}`, false);
|
||||
await viewerSession.loadDoc(`/doc/${doc.id}`, {wait: false});
|
||||
await gu.checkLoginPage();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import uuidv4 from "uuid/v4";
|
||||
describe("Fork", function() {
|
||||
|
||||
// this is a relatively slow test in staging.
|
||||
this.timeout(40000);
|
||||
this.timeout(60000);
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
let doc: DocCreationInfo;
|
||||
@@ -45,6 +45,7 @@ describe("Fork", function() {
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
|
||||
const forkUrl = await driver.getCurrentUrl();
|
||||
assert.match(forkUrl, isLoggedIn ? /^[^~]*~[^~]+~\d+$/ : /^[^~]*~[^~]*$/);
|
||||
await gu.refreshDismiss();
|
||||
await session.loadDoc((new URL(forkUrl)).pathname + '/m/fork');
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
await gu.enterCell('2');
|
||||
@@ -57,6 +58,7 @@ describe("Fork", function() {
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
|
||||
assert.equal(await driver.getCurrentUrl(), `${forkUrl}/m/fork`);
|
||||
await gu.wipeToasts();
|
||||
await gu.refreshDismiss();
|
||||
}
|
||||
|
||||
// Run tests with both regular docId and a custom urlId in URL, to make sure
|
||||
@@ -120,6 +122,7 @@ describe("Fork", function() {
|
||||
await gu.enterCell('123');
|
||||
await gu.waitForServer();
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
|
||||
await gu.refreshDismiss();
|
||||
// edits should persist across reloads
|
||||
await personal.loadDoc(`/doc/${id}`);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
|
||||
@@ -128,16 +131,17 @@ describe("Fork", function() {
|
||||
await gu.enterCell('234');
|
||||
await gu.waitForServer();
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '234');
|
||||
await gu.refreshDismiss();
|
||||
|
||||
if (mode !== 'anonymous') {
|
||||
// if we log out, access should now be denied
|
||||
const anonSession = await personal.anon.login();
|
||||
await anonSession.loadDoc(`/doc/${id}`, false);
|
||||
await anonSession.loadDoc(`/doc/${id}`, {wait: false});
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
|
||||
// if we log in as a different user, access should be denied
|
||||
const altSession = await personal.user('user2').login();
|
||||
await altSession.loadDoc(`/doc/${id}`, false);
|
||||
await altSession.loadDoc(`/doc/${id}`, {wait: false});
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
}
|
||||
});
|
||||
@@ -179,6 +183,7 @@ describe("Fork", function() {
|
||||
assert.match(forkUrl, /~/);
|
||||
// check that the tag is there
|
||||
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
|
||||
await gu.refreshDismiss();
|
||||
|
||||
// Open the original url and make sure the change we made is not there
|
||||
await anonSession.loadDoc(`/doc/${doc.id}`);
|
||||
@@ -202,6 +207,7 @@ describe("Fork", function() {
|
||||
await gu.waitForServer();
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
|
||||
const fork1 = await driver.getCurrentUrl();
|
||||
await gu.refreshDismiss();
|
||||
|
||||
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
@@ -209,6 +215,7 @@ describe("Fork", function() {
|
||||
await gu.waitForServer();
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
|
||||
const fork2 = await driver.getCurrentUrl();
|
||||
await gu.refreshDismiss();
|
||||
|
||||
await anonSession.loadDoc((new URL(fork1)).pathname);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
|
||||
@@ -229,6 +236,7 @@ describe("Fork", function() {
|
||||
const urlId1 = await gu.getCurrentUrlId();
|
||||
assert.match(urlId1!, /~/);
|
||||
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table2/);
|
||||
await gu.refreshDismiss();
|
||||
});
|
||||
|
||||
for (const user of [{access: 'viewers', name: 'user3'},
|
||||
@@ -258,6 +266,7 @@ describe("Fork", function() {
|
||||
const fork = forks.find(f => f.id === forkId);
|
||||
assert.isDefined(fork);
|
||||
assert.equal(fork!.trunkId, doc.id);
|
||||
await gu.refreshDismiss();
|
||||
|
||||
// Open the original url and make sure the change we made is not there
|
||||
await userSession.loadDoc(`/doc/${doc.id}`);
|
||||
@@ -274,6 +283,7 @@ describe("Fork", function() {
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
await gu.enterCell('1234');
|
||||
await gu.waitForServer();
|
||||
await gu.refreshDismiss();
|
||||
await userSession.loadDoc((new URL(forkUrl)).pathname);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
|
||||
|
||||
@@ -286,6 +296,7 @@ describe("Fork", function() {
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
await gu.enterCell('12345');
|
||||
await gu.waitForServer();
|
||||
await gu.refreshDismiss();
|
||||
await team.loadDoc((new URL(forkUrl)).pathname);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
|
||||
|
||||
@@ -296,6 +307,7 @@ describe("Fork", function() {
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
await gu.enterCell('12345');
|
||||
await gu.waitForServer();
|
||||
await gu.refreshDismiss();
|
||||
await anonSession.loadDoc((new URL(forkUrl)).pathname);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
|
||||
});
|
||||
@@ -314,6 +326,7 @@ describe("Fork", function() {
|
||||
// The url of the doc should now be that of a fork
|
||||
const forkUrl = await driver.getCurrentUrl();
|
||||
assert.match(forkUrl, /~/);
|
||||
await gu.refreshDismiss();
|
||||
|
||||
// Open the original url and make sure the change we made is not there
|
||||
await team.loadDoc(`/doc/${doc2.id}`);
|
||||
@@ -327,6 +340,7 @@ describe("Fork", function() {
|
||||
await gu.getCell({rowNum: 1, col: 0}).click();
|
||||
await gu.enterCell('1234');
|
||||
await gu.waitForServer();
|
||||
await gu.refreshDismiss();
|
||||
await team.loadDoc((new URL(forkUrl)).pathname);
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
|
||||
|
||||
@@ -350,7 +364,7 @@ describe("Fork", function() {
|
||||
await team.login();
|
||||
const userId = await team.getUserId();
|
||||
const altSession = await team.user('user2').login();
|
||||
await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
|
||||
await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
|
||||
|
||||
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
|
||||
@@ -365,30 +379,30 @@ describe("Fork", function() {
|
||||
// would have some access to fork via acls on trunk, but for a
|
||||
// new doc user2 has no access granted via the doc, or
|
||||
// workspace, or org).
|
||||
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
|
||||
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
|
||||
|
||||
// Same, but as an anonymous user.
|
||||
const anonSession = await altSession.anon.login();
|
||||
await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
|
||||
await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
|
||||
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
|
||||
|
||||
// A new doc cannot be created either (because of access mismatch).
|
||||
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
|
||||
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
|
||||
|
||||
// Now as a user who *is* allowed to create the fork.
|
||||
// But doc forks cannot be casually created this way anymore, so it still doesn't work.
|
||||
await team.login();
|
||||
await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
|
||||
await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
|
||||
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
|
||||
|
||||
// New document can no longer be casually created this way anymore either.
|
||||
await team.loadDoc(`/doc/new~${forkId}~${userId}`, false);
|
||||
await team.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
|
||||
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
|
||||
await gu.wipeToasts();
|
||||
@@ -406,6 +420,7 @@ describe("Fork", function() {
|
||||
await gu.waitForServer();
|
||||
const forkUrl = await driver.getCurrentUrl();
|
||||
assert.match(forkUrl, /~/);
|
||||
await gu.refreshDismiss();
|
||||
|
||||
// check that there is no tag on trunk
|
||||
await team.loadDoc(`/doc/${trunk.id}`);
|
||||
@@ -444,6 +459,7 @@ describe("Fork", function() {
|
||||
await gu.waitForDocToLoad();
|
||||
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
|
||||
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
|
||||
await gu.refreshDismiss();
|
||||
});
|
||||
|
||||
it('disables document renaming for forks', async function() {
|
||||
@@ -456,6 +472,7 @@ describe("Fork", function() {
|
||||
await gu.waitToPass(async () => {
|
||||
assert.equal(await driver.find('.test-bc-doc').getAttribute('disabled'), 'true');
|
||||
});
|
||||
await gu.refreshDismiss();
|
||||
});
|
||||
|
||||
it('navigating browser history play well with the add new menu', async function() {
|
||||
@@ -506,6 +523,7 @@ describe("Fork", function() {
|
||||
// check we're on a fork
|
||||
await gu.waitForUrl(/~/);
|
||||
const urlId = await gu.getCurrentUrlId();
|
||||
await gu.refreshDismiss();
|
||||
|
||||
// open trunk again, to test that page is reloaded after replacement
|
||||
await team.loadDoc(`/doc/${doc.id}`);
|
||||
@@ -606,6 +624,7 @@ describe("Fork", function() {
|
||||
assert.equal(await confirmButton.getText(), 'Update');
|
||||
assert.match(await driver.find('.test-modal-dialog').getText(),
|
||||
/already identical/);
|
||||
await gu.refreshDismiss();
|
||||
});
|
||||
|
||||
it('gives an error when replacing without write access via UI', async function() {
|
||||
@@ -638,6 +657,7 @@ describe("Fork", function() {
|
||||
await driver.find('.test-replace-original').click();
|
||||
assert.equal(await driver.find('.grist-floating-menu').isDisplayed(), true);
|
||||
await assert.isRejected(driver.findWait('.test-modal-dialog', 500), /Waiting for element/);
|
||||
await gu.refreshDismiss();
|
||||
} finally {
|
||||
await api.updateDocPermissions(doc.id, {users: {[altSession.email]: null}});
|
||||
}
|
||||
|
||||
44
test/nbrowser/GridView.ts
Normal file
44
test/nbrowser/GridView.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {assert, driver} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import {DocCreationInfo} from "app/common/DocListAPI";
|
||||
|
||||
describe('GridView', function() {
|
||||
this.timeout(20000);
|
||||
const cleanup = setupTestSuite();
|
||||
let session: gu.Session, doc: DocCreationInfo, api;
|
||||
|
||||
it('should show tables with no columns without errors', async function() {
|
||||
session = await gu.session().login();
|
||||
doc = await session.tempDoc(cleanup, 'Hello.grist');
|
||||
api = session.createHomeApi();
|
||||
|
||||
// Create and open a new table with no columns
|
||||
await api.applyUserActions(doc.id, [
|
||||
['AddTable', 'Empty', []],
|
||||
]);
|
||||
await gu.getPageItem(/Empty/).click();
|
||||
|
||||
// The only 'column' should be the button to add a column
|
||||
const columnNames = await driver.findAll('.column_name', e => e.getText());
|
||||
assert.deepEqual(columnNames, ['+']);
|
||||
|
||||
// There should be no errors
|
||||
assert.lengthOf(await driver.findAll('.test-notifier-toast-wrapper'), 0);
|
||||
});
|
||||
|
||||
// When a grid is scrolled, and then data is changed (due to click in a linked section), some
|
||||
// records are not rendered or the position of the scroll container is corrupted.
|
||||
it('should render list with wrapped choices correctly', async function() {
|
||||
await session.tempDoc(cleanup, 'Teams.grist');
|
||||
await gu.selectSectionByTitle("PROJECTS");
|
||||
await gu.getCell(0, 1).click();
|
||||
await gu.selectSectionByTitle("TODO");
|
||||
await gu.scrollActiveView(0, 300);
|
||||
await gu.selectSectionByTitle("PROJECTS");
|
||||
await gu.getCell(0, 2).click();
|
||||
await gu.selectSectionByTitle("TODO");
|
||||
// This throws an error, as the cell is not rendered.
|
||||
assert.equal(await gu.getCell(0, 2).getText(), "2021-09-27 Mo\n2021-10-04 Mo");
|
||||
});
|
||||
});
|
||||
@@ -323,6 +323,7 @@ async function checkDocAndRestore(
|
||||
await docChecker();
|
||||
for (let i = 0; i < stepsBackToDocMenu; i++) {
|
||||
await driver.navigate().back();
|
||||
if (await gu.isAlertShown()) { await gu.acceptAlert(); }
|
||||
}
|
||||
await gu.waitForDocMenuToLoad();
|
||||
// If not logged in, we create docs "unsaved" and don't see them in doc-menu.
|
||||
|
||||
212
test/nbrowser/ImportReferences.ts
Normal file
212
test/nbrowser/ImportReferences.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Parsing strings as references when importing into an existing table
|
||||
*/
|
||||
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {openSource as openSourceMenu, waitForColumnMapping} from 'test/nbrowser/importerTestUtils';
|
||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
|
||||
describe('ImportReferences', function() {
|
||||
this.timeout(30000);
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
before(async function() {
|
||||
// Log in and import a sample document.
|
||||
const session = await gu.session().teamSite.user('user1').login();
|
||||
await session.tempDoc(cleanup, 'ImportReferences.grist');
|
||||
});
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
it('should convert strings to references', async function() {
|
||||
// Import a CSV file containing strings representing references
|
||||
await gu.importFileDialog('./uploads/name_references.csv');
|
||||
assert.equal(await driver.findWait('.test-importer-preview', 2000).isPresent(), true);
|
||||
|
||||
// Change the destination to the existing table
|
||||
await driver.findContent('.test-importer-target-existing-table', /Table1/).click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Finish import, and verify the import succeeded.
|
||||
await driver.find('.test-modal-confirm').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Verify data was imported to Names correctly.
|
||||
assert.deepEqual(
|
||||
await gu.getVisibleGridCells({rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2]}),
|
||||
[
|
||||
// Previously existing data in the fixture document
|
||||
'Alice', '', '',
|
||||
'Bob', '', '',
|
||||
|
||||
// Imported data from the CSV file
|
||||
// The second column is references which have been successfully parsed from strings
|
||||
// The third column is a formula equal to the second column to demonstrate the references
|
||||
'Charlie', 'Alice', 'Table1[1]',
|
||||
'Dennis', 'Bob', 'Table1[2]',
|
||||
|
||||
// 'add new' row
|
||||
'', '', '',
|
||||
]
|
||||
);
|
||||
|
||||
// TODO this test relies on the imported data referring to names (Alice,Bob)
|
||||
// already existing in the table before the import, and not being changed by the import
|
||||
});
|
||||
|
||||
it('should support importing into any reference columns and show preview', async function() {
|
||||
// Switch to page showing Projects and Tasks.
|
||||
await gu.getPageItem('Projects').click();
|
||||
await gu.waitForServer(); // wait for table load
|
||||
|
||||
// Load up a CSV file that matches the structure of the Tasks table.
|
||||
await gu.importFileDialog('./uploads/ImportReferences-Tasks.csv');
|
||||
|
||||
// The default import into "New Table" just shows the content of the file.
|
||||
assert.equal(await driver.findWait('.test-importer-preview', 2000).isPresent(), true);
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 4], mapper), [
|
||||
'Foo2', 'Clean', '1000', '1,000', '27 Mar 2023', '', '0',
|
||||
'Bar2', 'Wash', '3000', '2,000', '', 'Projects[2]', '2',
|
||||
'Baz2', 'Build2', '', '2', '20 Mar 2023', 'Projects[1]', '1',
|
||||
'Zoo2', 'Clean', '2000', '4,000', '24 Apr 2023', 'Projects[3]', '3',
|
||||
]);
|
||||
|
||||
await driver.findContent('.test-importer-target-existing-table', /Tasks/).click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// See that preview works, and cells that should be valid are valid.
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
|
||||
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
|
||||
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
|
||||
]);
|
||||
|
||||
await driver.find('.test-modal-confirm').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Verify data was imported to Tasks correctly.
|
||||
assert.deepEqual(
|
||||
await gu.getVisibleGridCells({section: 'TASKS', cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper}), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
// Previous data in the fixture, in row 4
|
||||
'Zoo', 'Clean', '2,000', '27 Mar 2023', 'Projects[3]',
|
||||
// New rows (values like "!Project[2]" are invalid, which may be fixed in the future).
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
|
||||
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
|
||||
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
|
||||
// 'Add New' row
|
||||
'', '', '', '', '',
|
||||
]);
|
||||
|
||||
await gu.undo();
|
||||
});
|
||||
|
||||
it('should support importing numeric columns as lookups or rowIDs', async function() {
|
||||
// Load up the same CSV file again, with Tasks as the destination.
|
||||
await gu.importFileDialog('./uploads/ImportReferences-Tasks.csv');
|
||||
await driver.findContent('.test-importer-target-existing-table', /Tasks/).click();
|
||||
await gu.waitForServer();
|
||||
await waitForColumnMapping();
|
||||
|
||||
// Check that preview works, and cells are valid.
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
|
||||
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
|
||||
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
|
||||
]);
|
||||
|
||||
// Check that dropdown for Label does not include "(as row ID)" entries, but the dropdown for
|
||||
// PName (a reference column) does.
|
||||
await openSourceMenu('Label');
|
||||
assert.equal(await findColumnMenuItem('PIndex').isPresent(), true);
|
||||
assert.equal(await findColumnMenuItem(/as row ID/).isPresent(), false);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
|
||||
await openSourceMenu('PName');
|
||||
assert.equal(await findColumnMenuItem('PIndex').isPresent(), true);
|
||||
assert.equal(await findColumnMenuItem('PIndex (as row ID)').isPresent(), true);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
|
||||
// Change PIndex column from lookup to row ID.
|
||||
await openSourceMenu('PIndex');
|
||||
await findColumnMenuItem('PIndex (as row ID)').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// The values become invalid because there are no such rowIDs.
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
'Foo2', 'Clean', '!1000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '!3000', '', '!Projects[2]',
|
||||
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
|
||||
'Zoo2', 'Clean', '!2000', '24 Apr 2023', '!Projects[3]',
|
||||
]);
|
||||
|
||||
// Try a lookup using PIndex2. It is differently formatted, one value is invalid, and one is a
|
||||
// valid row ID (but shouldn't be seen as a rowID for a lookup)
|
||||
await openSourceMenu('PIndex');
|
||||
await findColumnMenuItem('PIndex2').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Note: two PIndex values are different, and two are invalid.
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '2,000', '', '!Projects[2]',
|
||||
'Baz2', '!Build2', '!2.0', '!2023-03-20', '!Projects[1]',
|
||||
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', '!Projects[3]',
|
||||
]);
|
||||
|
||||
// Change PRowID column to use "PID (as row ID)". It has 3 valid rowIDs.
|
||||
await openSourceMenu('PRowID');
|
||||
await findColumnMenuItem('PID (as row ID)').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Note: PRowID values are now valid.
|
||||
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '2,000', '', 'Projects[2]',
|
||||
'Baz2', '!Build2', '!2.0', '!2023-03-20', 'Projects[1]',
|
||||
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', 'Projects[3]',
|
||||
]);
|
||||
|
||||
await driver.find('.test-modal-confirm').click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// Verify data was imported to Tasks correctly.
|
||||
assert.deepEqual(
|
||||
await gu.getVisibleGridCells({section: 'TASKS', cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper}), [
|
||||
// Label, PName, PIndex, PDate, PRowID
|
||||
// Previous data in the fixture, in row 4
|
||||
'Zoo', 'Clean', '2,000', '27 Mar 2023', 'Projects[3]',
|
||||
// New rows; PRowID values are valid.
|
||||
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
|
||||
'Bar2', 'Wash', '2,000', '', 'Projects[2]',
|
||||
'Baz2', '!Build2', '!2.0', '!2023-03-20', 'Projects[1]',
|
||||
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', 'Projects[3]',
|
||||
// 'Add New' row
|
||||
'', '', '', '', '',
|
||||
]);
|
||||
|
||||
await gu.undo();
|
||||
});
|
||||
});
|
||||
|
||||
// mapper for getVisibleGridCells and getPreviewContents to get both text and whether the cell is
|
||||
// invalid (pink). Invalid cells prefixed with "!".
|
||||
async function mapper(el: WebElement) {
|
||||
let text = await el.getText();
|
||||
if (await el.find(".field_clip").matches(".invalid")) {
|
||||
text = "!" + text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function findColumnMenuItem(label: RegExp|string) {
|
||||
return driver.findContent('.test-importer-column-match-menu-item', label);
|
||||
}
|
||||
@@ -36,8 +36,7 @@ describe("Smoke", function() {
|
||||
await gu.dismissWelcomeTourIfNeeded();
|
||||
await gu.getCell('A', 1).click();
|
||||
await gu.enterCell('123');
|
||||
await driver.navigate().refresh();
|
||||
await gu.waitForDocToLoad();
|
||||
await gu.refreshDismiss();
|
||||
assert.equal(await gu.getCell('A', 1).getText(), '123');
|
||||
});
|
||||
});
|
||||
|
||||
143
test/nbrowser/UploadLimits.ts
Normal file
143
test/nbrowser/UploadLimits.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Test of the importing logic in the DocMenu page.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import {assert, driver, Key} from 'mocha-webdriver';
|
||||
import * as tmp from 'tmp-promise';
|
||||
import * as util from 'util';
|
||||
|
||||
import { SQLiteDB } from 'app/server/lib/SQLiteDB';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import { copyFixtureDoc } from 'test/server/testUtils';
|
||||
|
||||
const write = util.promisify(fs.write);
|
||||
|
||||
describe('UploadLimits', function() {
|
||||
this.timeout(20000);
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
const cleanupCbs: Array<() => void> = [];
|
||||
|
||||
async function generateFile(postfix: string, size: number): Promise<string> {
|
||||
const obj = await tmp.file({postfix, mode: 0o644});
|
||||
await write(obj.fd, Buffer.alloc(size, 't'));
|
||||
cleanupCbs.push(obj.cleanup);
|
||||
return obj.path;
|
||||
}
|
||||
|
||||
// Create a valid Grist file of at least the desired length. The file may be
|
||||
// slightly larger than requested.
|
||||
async function generateGristFile(minSize: number): Promise<string> {
|
||||
const obj = await tmp.file({postfix: '.grist', mode: 0o644});
|
||||
await copyFixtureDoc('Hello.grist', obj.path);
|
||||
const size = fs.statSync(obj.path).size;
|
||||
const db = await SQLiteDB.openDBRaw(obj.path);
|
||||
// Make a string that is long enough to push the doc over the required size.
|
||||
const longString = 'x'.repeat(Math.max(1, minSize - size));
|
||||
// Add the string somewhere in the doc. For now we place it in a separate
|
||||
// table - this may eventually become invalid, but it works for now.
|
||||
// There'll be a little overhead so we'll overshoot the target length a bit,
|
||||
// but that's fine.
|
||||
await db.exec('CREATE TABLE _gristsys_extra(txt)');
|
||||
await db.run('INSERT INTO _gristsys_extra(txt) VALUES(?)', [longString]);
|
||||
await db.close();
|
||||
const size2 = fs.statSync(obj.path).size;
|
||||
if (size2 < minSize || size2 > minSize * 1.2) {
|
||||
throw new Error(`generateGristFile size is off, wanted ${minSize}, got ${size2}`);
|
||||
}
|
||||
cleanupCbs.push(obj.cleanup);
|
||||
return obj.path;
|
||||
}
|
||||
|
||||
after(function() {
|
||||
for (const cleanup of cleanupCbs) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await gu.checkForErrors();
|
||||
});
|
||||
|
||||
const maxImport = 1024 * 1024; // See GRIST_MAX_UPLOAD_IMPORT_MB = 1 in testServer.ts
|
||||
const maxAttachment = 2 * 1024 * 1024; // See GRIST_MAX_UPLOAD_ATTACHMENT_MB = 2 in testServer.ts
|
||||
|
||||
it('should prevent large uploads for imports', async function() {
|
||||
const session = await gu.session().teamSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
// Generate and upload a large csv file. It should by blocked on the client side.
|
||||
const largeFilePath = await generateFile(".csv", maxImport + 1000);
|
||||
await gu.docMenuImport(largeFilePath);
|
||||
|
||||
// Ensure an error is shown.
|
||||
assert.match(await driver.findWait('.test-notifier-toast-message', 1000).getText(),
|
||||
/Imported files may not exceed 1.0MB/);
|
||||
|
||||
// Now try to import directly to server, and verify that the server enforces this limit too.
|
||||
const p = gu.importFixturesDoc('Chimpy', 'nasa', 'Horizon', largeFilePath, {load: false});
|
||||
await assert.isRejected(p, /Payload Too Large/);
|
||||
const err = await p.catch((e) => e);
|
||||
assert.equal(err.status, 413);
|
||||
assert.isObject(err.details);
|
||||
assert.match(err.details.userError, /Imported files must not exceed 1.0MB/);
|
||||
});
|
||||
|
||||
it('should allow large uploads of .grist docs', async function() {
|
||||
const session = await gu.session().teamSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
// Generate and upload a large .grist file. It should not be subject to limits.
|
||||
const largeFilePath = await generateGristFile(maxImport * 2 + 1000);
|
||||
await gu.docMenuImport(largeFilePath);
|
||||
|
||||
await gu.waitForDocToLoad();
|
||||
assert.equal(await gu.getCell(0, 1).getText(), 'hello');
|
||||
});
|
||||
|
||||
it('should prevent large uploads for attachments', async function() {
|
||||
const session = await gu.session().teamSite.login();
|
||||
await session.tempDoc(cleanup, 'Hello.grist');
|
||||
|
||||
// Clear the first cell.
|
||||
await gu.getCell(0, 1).click();
|
||||
await driver.sendKeys(Key.DELETE);
|
||||
await gu.waitForServer();
|
||||
|
||||
// Change column to Attachments.
|
||||
await gu.toggleSidePanel('right', 'open');
|
||||
await driver.find('.test-right-tab-field').click();
|
||||
await gu.setType(/Attachment/);
|
||||
await driver.findWait('.test-type-transform-apply', 1000).click();
|
||||
await gu.waitForServer();
|
||||
|
||||
// We can upload multiple smaller files (the limit is per-file here).
|
||||
const largeFilePath1 = await generateFile(".png", maxAttachment - 1000);
|
||||
const largeFilePath2 = await generateFile(".jpg", maxAttachment - 1000);
|
||||
await gu.fileDialogUpload([largeFilePath1, largeFilePath2].join(","),
|
||||
() => gu.getCell(0, 1).find('.test-attachment-icon').click());
|
||||
await gu.getCell(0, 1).findWait('.test-attachment-widget > [class*=test-pw-]', 1000);
|
||||
|
||||
// We don't expect any errors here.
|
||||
assert.lengthOf(await driver.findAll('.test-notifier-toast-wrapper'), 0);
|
||||
|
||||
// Expect to find two attachments in the cell.
|
||||
assert.lengthOf(await gu.getCell(0, 1).findAll('.test-attachment-widget > [class*=test-pw-]'), 2);
|
||||
|
||||
// But we can't upload larger files, even one at a time.
|
||||
const largeFilePath3 = await generateFile(".jpg", maxAttachment + 1000);
|
||||
await gu.fileDialogUpload(largeFilePath3,
|
||||
() => gu.getCell(0, 2).find('.test-attachment-icon').click());
|
||||
await driver.sleep(200);
|
||||
await gu.waitForServer();
|
||||
|
||||
// Check that there is a warning and the cell hasn't changed.
|
||||
assert.match(await driver.findWait('.test-notifier-toast-message', 1000).getText(),
|
||||
/Attachments may not exceed 2.0MB/);
|
||||
assert.lengthOf(await gu.getCell(0, 2).findAll('.test-attachment-widget > [class*=test-pw-]'), 0);
|
||||
|
||||
// TODO We should try to add attachment via API and verify that the server enforces the limit
|
||||
// too, but at the moment we don't have an endpoint to add attachments via the API.
|
||||
});
|
||||
});
|
||||
@@ -1959,8 +1959,16 @@ export class Session {
|
||||
}
|
||||
|
||||
// Load a document on a site.
|
||||
public async loadDoc(relPath: string, wait: boolean = true) {
|
||||
public async loadDoc(
|
||||
relPath: string,
|
||||
options: {
|
||||
wait?: boolean,
|
||||
skipAlert?: boolean,
|
||||
} = {}
|
||||
) {
|
||||
const {wait = true, skipAlert = false} = options;
|
||||
await this.loadRelPath(relPath);
|
||||
if (skipAlert && await isAlertShown()) { await acceptAlert(); }
|
||||
if (wait) { await waitForDocToLoad(); }
|
||||
}
|
||||
|
||||
@@ -2395,6 +2403,7 @@ async function addSamples() {
|
||||
await templatesApi.updateDoc(
|
||||
exampleDocId,
|
||||
{
|
||||
type: 'template',
|
||||
isPinned: true,
|
||||
options: {
|
||||
description: 'CRM template and example for linking data, and creating productive layouts.',
|
||||
@@ -2411,6 +2420,7 @@ async function addSamples() {
|
||||
await templatesApi.updateDoc(
|
||||
investmentDocId,
|
||||
{
|
||||
type: 'template',
|
||||
isPinned: true,
|
||||
options: {
|
||||
description: 'Example for analyzing and visualizing with summary tables and linked charts.',
|
||||
@@ -2425,6 +2435,7 @@ async function addSamples() {
|
||||
await templatesApi.updateDoc(
|
||||
afterschoolDocId,
|
||||
{
|
||||
type: 'template',
|
||||
isPinned: true,
|
||||
options: {
|
||||
description: 'Example for how to model business data, use formulas, and manage complexity.',
|
||||
@@ -2870,10 +2881,29 @@ export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
|
||||
*/
|
||||
export async function refreshDismiss() {
|
||||
await driver.navigate().refresh();
|
||||
await (await driver.switchTo().alert()).accept();
|
||||
await acceptAlert();
|
||||
await waitForDocToLoad();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts an alert.
|
||||
*/
|
||||
export async function acceptAlert() {
|
||||
await (await driver.switchTo().alert()).accept();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an alert is shown.
|
||||
*/
|
||||
export async function isAlertShown() {
|
||||
try {
|
||||
await driver.switchTo().alert();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses any tutorial card that might be active.
|
||||
*/
|
||||
|
||||
@@ -112,6 +112,7 @@ export class HomeUtil {
|
||||
}
|
||||
// Make sure we revisit page in case login is changing.
|
||||
await this.driver.get('about:blank');
|
||||
await this._acceptAlertIfPresent();
|
||||
// When running against an external server, we log in through the Grist login page.
|
||||
await this.driver.get(this.server.getUrl(org, ""));
|
||||
if (!await this.isOnLoginPage()) {
|
||||
@@ -157,6 +158,7 @@ export class HomeUtil {
|
||||
if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }
|
||||
} else {
|
||||
await this.driver.get(`${this.server.getHost()}/logout`);
|
||||
await this._acceptAlertIfPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +253,7 @@ export class HomeUtil {
|
||||
public async getGristSid(): Promise<string|null> {
|
||||
// Load a cheap page on our server to get the session-id cookie from browser.
|
||||
await this.driver.get(`${this.server.getHost()}/test/session`);
|
||||
await this._acceptAlertIfPresent();
|
||||
const cookie = await this.driver.manage().getCookie(process.env.GRIST_SESSION_COOKIE || 'grist_sid');
|
||||
if (!cookie) { return null; }
|
||||
return decodeURIComponent(cookie.value);
|
||||
@@ -475,4 +478,12 @@ export class HomeUtil {
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _acceptAlertIfPresent() {
|
||||
try {
|
||||
await (await this.driver.switchTo().alert()).accept();
|
||||
} catch {
|
||||
/* There was no alert to accept. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
test/nbrowser/importerTestUtils.ts
Normal file
74
test/nbrowser/importerTestUtils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Testing utilities used in Importer test suites.
|
||||
*/
|
||||
|
||||
import {driver, stackWrapFunc, WebElementPromise} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
|
||||
// Helper to get the input of a matching parse option in the ParseOptions dialog.
|
||||
export const getParseOptionInput = stackWrapFunc((labelRE: RegExp): WebElementPromise =>
|
||||
driver.findContent('.test-parseopts-opt', labelRE).find('input'));
|
||||
|
||||
type CellDiff = string|[string|undefined, string|undefined, string|undefined];
|
||||
|
||||
/**
|
||||
* Returns preview diff cell values when the importer is updating existing records.
|
||||
*
|
||||
* If a cell has no diff, just the cell value is returned. Otherwise, a 3-tuple
|
||||
* containing the parent, remote, and common values (in that order) is returned.
|
||||
*/
|
||||
export const getPreviewDiffCellValues = stackWrapFunc(async (cols: number[], rowNums: number[]) => {
|
||||
return gu.getPreviewContents<CellDiff>(cols, rowNums, async (cell) => {
|
||||
const hasParentDiff = await cell.find('.diff-parent').isPresent();
|
||||
const hasRemoteDiff = await cell.find('.diff-remote').isPresent();
|
||||
const hasCommonDiff = await cell.find('.diff-common').isPresent();
|
||||
|
||||
const isDiff = hasParentDiff || hasRemoteDiff || hasCommonDiff;
|
||||
return !isDiff ? await cell.getText() :
|
||||
[
|
||||
hasParentDiff ? await cell.find('.diff-parent').getText() : undefined,
|
||||
hasRemoteDiff ? await cell.find('.diff-remote').getText() : undefined,
|
||||
hasCommonDiff ? await cell.find('.diff-common').getText() : undefined,
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
// Helper that waits for the diff preview to finish loading.
|
||||
export const waitForDiffPreviewToLoad = stackWrapFunc(async (): Promise<void> => {
|
||||
await driver.wait(() => driver.find('.test-importer-preview').isPresent(), 5000);
|
||||
});
|
||||
|
||||
// Helper that gets the list of visible column matching rows to the left of the preview.
|
||||
export const getColumnMatchingRows = stackWrapFunc(async (): Promise<{source: string, destination: string}[]> => {
|
||||
return await driver.findAll('.test-importer-column-match-source-destination', async (el) => {
|
||||
const source = await el.find('.test-importer-column-match-formula').getText();
|
||||
const destination = await el.find('.test-importer-column-match-destination').getText();
|
||||
return {source, destination};
|
||||
});
|
||||
});
|
||||
|
||||
export async function waitForColumnMapping() {
|
||||
await driver.wait(() => driver.find(".test-importer-column-match-options").isDisplayed(), 300);
|
||||
}
|
||||
|
||||
export async function openColumnMapping() {
|
||||
const selected = driver.find('.test-importer-target-selected');
|
||||
await selected.find('.test-importer-target-column-mapping').click();
|
||||
await driver.sleep(200); // animation
|
||||
await waitForColumnMapping();
|
||||
}
|
||||
|
||||
export async function openTableMapping() {
|
||||
await driver.find('.test-importer-table-mapping').click();
|
||||
await driver.sleep(200); // animation
|
||||
await driver.wait(() => driver.find(".test-importer-target").isDisplayed(), 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the menu for the destination column, by clicking the source.
|
||||
*/
|
||||
export async function openSource(text: string|RegExp) {
|
||||
await driver.findContent('.test-importer-column-match-destination', text)
|
||||
.findClosest('.test-importer-column-match-source-destination')
|
||||
.find('.test-importer-column-match-source').click();
|
||||
}
|
||||
Reference in New Issue
Block a user