import {safeJsonParse} from 'app/common/gutil'; import * as chai from 'chai'; import {assert, driver, Key} from 'mocha-webdriver'; import {serveCustomViews, Serving, setAccess} from 'test/nbrowser/customUtil'; import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; chai.config.truncateThreshold = 5000; describe('CustomView', function() { this.timeout(20000); gu.bigScreen(); const cleanup = setupTestSuite(); let serving: Serving; before(async function() { if (server.isExternalServer()) { this.skip(); } serving = await serveCustomViews(); }); after(async function() { if (serving) { await serving.shutdown(); } }); // This tests if test id works. Feels counterintuitive to "test the test" but grist-widget repository test suite // depends on this. it('informs about ready called', async () => { // Add a custom widget to a new doc. const session = await gu.session().teamSite.login(); await session.tempNewDoc(cleanup); await gu.addNewSection('Custom', 'Table1'); // Point to a widget that doesn't immediately call ready. await gu.setCustomWidgetUrl(`${serving.url}/deferred-ready`, {openGallery: false}); await gu.toggleSidePanel('right', 'open'); // We should have a single iframe. assert.equal(await driver.findAll('iframe').then(f => f.length), 1); // But without test ready class. assert.isFalse(await driver.find("iframe.test-custom-widget-ready").isPresent()); // Now invoke ready. await inFrame(async () => { await driver.find('button').click(); }); // And we should have a test ready class. assert.isTrue(await driver.findWait("iframe.test-custom-widget-ready", 100).isDisplayed()); }); for (const access of ['none', 'read table', 'full'] as const) { function withAccess(obj: any, fallback: any) { return ((access !== 'none') && obj) || fallback; } function readJson(txt: string) { return safeJsonParse(txt, null); } describe(`with access level ${access}`, function() { before(async function() { if (server.isExternalServer()) { this.skip(); } const mainSession = await gu.session().teamSite.login(); await mainSession.tempDoc(cleanup, 'Favorite_Films.grist'); if (!await gu.isSidePanelOpen('right')) { await gu.toggleSidePanel('right'); } await driver.find('.test-config-data').click(); }); it('gets appropriate notification of row set changes', async function() { // Link a section on the "All" page of Favorite Films demo await driver.findContent('.test-treeview-itemHeader', /All/).click(); await gu.getSection('Friends record').click(); await driver.find('.test-pwc-editDataSelection').click(); await driver.find('.test-wselect-addBtn').click(); await gu.waitForServer(); await driver.find('.test-right-select-by').click(); await driver.findContent('.test-select-menu li', /Performances record • Film/).click(); await driver.find('.test-pwc-editDataSelection').click(); await driver.findContent('.test-wselect-type', /Custom/).click(); await driver.find('.test-wselect-addBtn').click(); await gu.waitForServer(); // Replace the widget with a custom widget that just reads out the data // as JSON. await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false}); await gu.openWidgetPanel(); await setAccess(access); await gu.waitForServer(); // Check that the data looks right. const iframe = gu.getSection('Friends record').find('iframe'); await driver.switchTo().frame(iframe); assert.deepEqual(readJson(await driver.find('#placeholder').getText()), withAccess({ Name: ["Tom"], Favorite_Film: ["Toy Story"], Age: ["25"], id: [2] }, null)); assert.equal(await driver.find('#rowId').getText(), withAccess('2', '')); assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); assert.deepEqual(readJson(await driver.find('#records').getText()), withAccess([{ Name: "Tom", // not a list! Favorite_Film: "Toy Story", Age: "25", id: 2 }], null)); await driver.switchTo().defaultContent(); // Switch row in source section, and see if data updates correctly. await gu.getCell({section: 'Performances record', col: 0, rowNum: 5}).click(); await driver.switchTo().frame(iframe); assert.deepEqual(readJson(await driver.find('#placeholder').getText()), withAccess({ Name: ["Roger", "Evan"], Favorite_Film: ["Forrest Gump", "Forrest Gump"], Age: ["22", "35"], id: [1, 5] }, null)); assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); assert.deepEqual(readJson(await driver.find('#records').getText()), withAccess([{ Name: "Roger", Favorite_Film: "Forrest Gump", Age: "22", id: 1 }, { Name: "Evan", Favorite_Film: "Forrest Gump", Age: "35", id: 5 }], null)); await driver.switchTo().defaultContent(); }); it('gets notification of row changes and content changes', async function() { // Add a custom view linked to Friends await driver.findContent('.test-treeview-itemHeader', /Friends/).click(); await driver.findWait('.test-dp-add-new', 1000).doClick(); await driver.find('.test-dp-add-widget-to-page').doClick(); await driver.findContent('.test-wselect-type', /Custom/).click(); await driver.findContent('.test-wselect-table', /Friends/).doClick(); await driver.find('.test-wselect-selectby').doClick(); await driver.findContent('.test-wselect-selectby option', /FRIENDS/).doClick(); await driver.find('.test-wselect-addBtn').click(); await gu.waitForServer(); // Choose the custom view that just reads out data as json await gu.setCustomWidgetUrl(`${serving.url}/readout`, {openGallery: false}); await gu.openWidgetPanel(); await setAccess(access); await gu.waitForServer(); // Check that data and cursor looks right const iframe = gu.getSection('Friends custom').find('iframe'); await driver.switchTo().frame(iframe); assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Roger', undefined)); assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, withAccess('Roger', undefined)); // Change row in Friends await driver.switchTo().defaultContent(); await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 2}).click(); // Check that rowId is updated await driver.switchTo().frame(iframe); assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); assert.equal(await driver.find('#rowId').getText(), withAccess('2', '')); assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Tom', undefined)); assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, withAccess('Roger', undefined)); await driver.switchTo().defaultContent(); // Change a cell in Friends await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); await gu.enterCell('Rabbit'); await gu.waitForServer(); // Return to the cell after automatically going to next row. await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); // Check the data in view updates await driver.switchTo().frame(iframe); assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, withAccess(['Rabbit', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Rabbit', undefined)); assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, withAccess('Rabbit', undefined)); await driver.switchTo().defaultContent(); // Select new row and test if custom view has noticed it. await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 7}).click(); await driver.switchTo().frame(iframe); assert.equal(await driver.find('#rowId').getText(), withAccess('new', '')); assert.equal(await driver.find('#record').getText(), withAccess('new', '')); await driver.switchTo().defaultContent(); await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); await driver.switchTo().frame(iframe); assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Rabbit', undefined)); await driver.switchTo().defaultContent(); // Revert the cell change await gu.undo(); }); const undoTestTitle = access === 'full' ? 'allows undo/redo via keyboard' : 'does not allow undo/redo via keyboard'; it (undoTestTitle, async function() { const iframe = gu.getSection('Friends custom').find('iframe'); await driver.switchTo().frame(iframe); await driver.find('body').click(); await gu.sendKeys(Key.chord(Key.CONTROL, 'y')); const expected = access === 'full' ? withAccess(['Rabbit', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined) : withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined); await gu.waitToPass(async () => { assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, expected); }, 1000); await gu.sendKeys(Key.chord(await gu.modKey(), 'z')); await gu.waitToPass(async () => { assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); }, 1000); await driver.switchTo().defaultContent(); }); it('allows switching to custom section by clicking inside it', async function() { await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false); const iframe = gu.getSection('Friends custom').find('iframe'); await driver.switchTo().frame(iframe); await driver.find('body').click(); // Check that the right section is active, and its settings visible in the side panel. await driver.switchTo().defaultContent(); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom'); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), true); // Switch back. await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); assert.equal(await driver.find('.test-config-widget-open-custom-widget-gallery').isPresent(), false); }); it('deals correctly with requests that require full access', async function() { // Choose a custom widget that tries to replace all cells in all user tables with 'zap'. await gu.getSection('Friends Custom').click(); await gu.setCustomWidgetUrl(`${serving.url}/zap`); await gu.openWidgetPanel(); await setAccess(access); await gu.waitForServer(); // Wait for widget to finish its work. const iframe = gu.getSection('Friends custom').find('iframe'); await driver.switchTo().frame(iframe); await gu.waitToPass(async () => { assert.match(await driver.find('#placeholder').getText(), /zap/); }, 10000); const outcome = await driver.find('#placeholder').getText(); await driver.switchTo().defaultContent(); const cell = await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).getText(); if (access === 'full') { assert.equal(cell, 'zap'); assert.match(outcome, /zap succeeded/); } else { assert.notEqual(cell, 'zap'); assert.match(outcome, /zap failed/); } }); }); } it('should receive friendly types when reading data from Grist', async function() { // TODO The same decoding should probably apply to calls like fetchTable() which are satisfied // by the server. const mainSession = await gu.session().teamSite.login(); await mainSession.tempDoc(cleanup, 'TypeEncoding.grist'); await gu.toggleSidePanel('right', 'open'); await driver.find('.test-right-tab-pagewidget').click(); await gu.waitForServer(); await driver.find('.test-config-data').click(); // The test doc already has a Custom View widget. It just needs to // have a URL set. await gu.getSection('TYPES custom').click(); await gu.setCustomWidgetUrl(`${serving.url}/types`); // If we needed to change widget to Custom URL, make sure access is read table. await setAccess("read table"); await gu.waitForServer(); const iframe = gu.getSection('TYPES custom').find('iframe'); await driver.switchTo().frame(iframe); await driver.findContentWait('#record', /AnyDate/, 1000000); let lines = (await driver.find('#record').getText()).split('\n'); // The first line has regular old values. assert.deepEqual(lines, [ "AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]", "AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]", "AnyRef: Types[2] [typeof=object] [name=Reference]", "Bool: true [typeof=boolean]", "Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]", "DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]", "Numeric: 17.25 [typeof=number]", "RECORD: [object Object] [typeof=object] [name=Object]", " AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]", " AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]", " AnyRef: Types[2] [typeof=object] [name=Reference]", " Bool: true [typeof=boolean]", " Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]", " DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]", " Numeric: 17.25 [typeof=number]", " Reference: Types[2] [typeof=object] [name=Reference]", " Text: Hello! [typeof=string]", " id: 24 [typeof=number]", "Reference: Types[2] [typeof=object] [name=Reference]", "Text: Hello! [typeof=string]", "id: 24 [typeof=number]", ]); // #match tells us if onRecords() returned the same representation for this record. assert.equal(await driver.find('#match').getText(), 'true'); // Switch to the next row, which has blank values. await driver.switchTo().defaultContent(); await gu.getCell({section: 'TYPES', col: 0, rowNum: 2}).click(); await driver.switchTo().frame(iframe); await driver.findContentWait('#record', /AnyDate: null/, 1000); lines = (await driver.find('#record').getText()).split('\n'); assert.deepEqual(lines, [ "AnyDate: null [typeof=object]", "AnyDateTime: null [typeof=object]", "AnyRef: Types[0] [typeof=object] [name=Reference]", "Bool: false [typeof=boolean]", "Date: null [typeof=object]", "DateTime: null [typeof=object]", "Numeric: 0 [typeof=number]", "RECORD: [object Object] [typeof=object] [name=Object]", " AnyDate: null [typeof=object]", " AnyDateTime: null [typeof=object]", " AnyRef: Types[0] [typeof=object] [name=Reference]", " Bool: false [typeof=boolean]", " Date: null [typeof=object]", " DateTime: null [typeof=object]", " Numeric: 0 [typeof=number]", " Reference: Types[0] [typeof=object] [name=Reference]", " Text: [typeof=string]", " id: 1 [typeof=number]", "Reference: Types[0] [typeof=object] [name=Reference]", "Text: [typeof=string]", "id: 1 [typeof=number]", ]); // #match tells us if onRecords() returned the same representation for this record. assert.equal(await driver.find('#match').getText(), 'true'); // Switch to the next row, which has various error values. await driver.switchTo().defaultContent(); await gu.getCell({section: 'TYPES', col: 0, rowNum: 3}).click(); await driver.switchTo().frame(iframe); await driver.findContentWait('#record', /AnyDate: null/, 1000); lines = (await driver.find('#record').getText()).split('\n'); assert.deepEqual(lines, [ "AnyDate: #Invalid Date: Not-a-Date [typeof=object] [name=RaisedException]", "AnyDateTime: #Invalid DateTime: Not-a-DateTime [typeof=object] [name=RaisedException]", "AnyRef: #AssertionError [typeof=object] [name=RaisedException]", "Bool: true [typeof=boolean]", "Date: Not-a-Date [typeof=string]", "DateTime: Not-a-DateTime [typeof=string]", "Numeric: Not-a-Number [typeof=string]", "RECORD: [object Object] [typeof=object] [name=Object]", " AnyDate: null [typeof=object]", " AnyDateTime: null [typeof=object]", " AnyRef: null [typeof=object]", " Bool: true [typeof=boolean]", " Date: Not-a-Date [typeof=string]", " DateTime: Not-a-DateTime [typeof=string]", " Numeric: Not-a-Number [typeof=string]", " Reference: No-Ref [typeof=string]", " Text: Errors [typeof=string]", " _error_: [object Object] [typeof=object] [name=Object]", " AnyDate: InvalidTypedValue: Invalid Date: Not-a-Date [typeof=string]", " AnyDateTime: InvalidTypedValue: Invalid DateTime: Not-a-DateTime [typeof=string]", " AnyRef: AssertionError: [typeof=string]", " id: 2 [typeof=number]", "Reference: No-Ref [typeof=string]", "Text: Errors [typeof=string]", "id: 2 [typeof=number]", ]); // #match tells us if onRecords() returned the same representation for this record. assert.equal(await driver.find('#match').getText(), 'true'); }); it('respect access rules', async function() { // Create a Favorite Films copy, with access rules on columns, rows, and tables. const mainSession = await gu.session().teamSite.login(); const api = mainSession.createHomeApi(); const doc = await mainSession.tempDoc(cleanup, 'Favorite_Films.grist', {load: false}); await api.applyUserActions(doc.id, [ ['AddTable', 'Opinions', [{id: 'A'}]], ['AddRecord', 'Opinions', null, {A: 'do not zap plz'}], ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Performances', colIds: 'Actor'}], ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}], ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Opinions', colIds: '*'}], ['AddRecord', '_grist_ACLRules', null, { resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'none', }], ['AddRecord', '_grist_ACLRules', null, { resource: -2, aclFormula: 'rec.id % 2 == 0', permissionsText: 'none', }], ['AddRecord', '_grist_ACLRules', null, { resource: -3, aclFormula: '', permissionsText: 'none', }], ]); // Open it up and add a new linked section. await mainSession.loadDoc(`/doc/${doc.id}`); await gu.toggleSidePanel('right', 'open'); await driver.find('.test-config-data').click(); await driver.findContent('.test-treeview-itemHeader', /All/).click(); await gu.getSection('Friends record').click(); await driver.find('.test-pwc-editDataSelection').click(); await driver.find('.test-wselect-addBtn').click(); await gu.waitForServer(); await driver.find('.test-right-select-by').click(); await driver.findContent('.test-select-menu li', /Performances record • Film/).click(); await driver.find('.test-pwc-editDataSelection').click(); await driver.findContent('.test-wselect-type', /Custom/).click(); await driver.find('.test-wselect-addBtn').click(); await gu.waitForServer(); // Select a custom widget that tries to replace all cells in all user tables with 'zap'. await gu.setCustomWidgetUrl(`${serving.url}/zap`, {openGallery: false}); await gu.openWidgetPanel(); await setAccess("full"); await gu.waitForServer(); // Wait for widget to finish its work. const iframe = gu.getSection('Friends record').find('iframe'); await driver.switchTo().frame(iframe); await gu.waitToPass(async () => { assert.match(await driver.find('#placeholder').getText(), /zap/); }, 10000); await driver.switchTo().defaultContent(); // Now leave the page and remove all access rules. await mainSession.loadDocMenu('/'); await api.applyUserActions(doc.id, [ ['BulkRemoveRecord', '_grist_ACLRules', [2, 3, 4]] ]); // Check that the expected cells got zapped. // In performances table, all but Actor column should have been zapped. const performances = await api.getDocAPI(doc.id).getRows('Performances'); let keys = Object.keys(performances); for (let i = 0; i < performances.id.length; i++) { for (const key of keys) { if (key !== 'Actor' && key !== 'id' && key !== 'manualSort') { // use match since zap may be embedded in an error, e.g. if inserted in ref column. assert.match(String(performances[key][i]), /zap/); } assert.notMatch(String(performances['Actor'][i]), /zap/); } } // In films table, every second row should have been zapped. const films = await api.getDocAPI(doc.id).getRows('Films'); keys = Object.keys(films); for (let i = 0; i < films.id.length; i++) { for (const key of keys) { if (key !== 'id' && key !== 'manualSort') { assert.equal(films[key][i] === 'zap', films.id[i] % 2 === 1); } } } // Opinions table should be untouched. const opinions = await api.getDocAPI(doc.id).getRows('Opinions'); assert.equal(opinions['A'][0], 'do not zap plz'); }); it('allows custom options for fetching data', async function () { const mainSession = await gu.session().teamSite.login(); const doc = await mainSession.tempDoc(cleanup, 'FetchSelectedOptions.grist', {load: false}); await mainSession.loadDoc(`/doc/${doc.id}`); await gu.getSection('TABLE1 Custom').click(); await gu.setCustomWidgetUrl(`${serving.url}/fetchSelectedOptions`); await gu.openWidgetPanel(); await setAccess("full"); await gu.waitForServer(); const expected = { "default": { "fetchSelectedTable": { "id": [1, 2], "A": [["a", "b"], ["c", "d"]], }, "fetchSelectedRecord": { "id": 1, "A": ["a", "b"] }, // The viewApi methods don't decode data by default, hence the "L" prefixes. "viewApiFetchSelectedTable": { "id": [1, 2], "A": [["L", "a", "b"], ["L", "c", "d"]], }, "viewApiFetchSelectedRecord": { "id": 2, "A": ["L", "c", "d"] }, // onRecords returns rows by default, not columns. "onRecords": [ {"id": 1, "A": ["a", "b"]}, {"id": 2, "A": ["c", "d"]} ], "onRecord": { "id": 1, "A": ["a", "b"] }, }, "options": { // This is the result of calling the same methods as above, // but with the values of `keepEncoded` and `format` being the opposite of their defaults. // `includeColumns` is also set to either 'normal' or 'all' instead of the default 'shown', // which means that the 'B' column is included in all the results, // and the 'manualSort' columns is included in half of them. "fetchSelectedTable": [ {"id": 1, "manualSort": 1, "A": ["L", "a", "b"], "B": 1}, {"id": 2, "manualSort": 2, "A": ["L", "c", "d"], "B": 2}, ], "fetchSelectedRecord": { "id": 1, "A": ["L", "a", "b"], "B": 1 }, "viewApiFetchSelectedTable": [ {"id": 1, "manualSort": 1, "A": ["a", "b"], "B": 1}, {"id": 2, "manualSort": 2, "A": ["c", "d"], "B": 2} ], "viewApiFetchSelectedRecord": { "id": 2, "A": ["c", "d"], "B": 2 }, "onRecords": { "id": [1, 2], "manualSort": [1, 2], "A": [["L", "a", "b"], ["L", "c", "d"]], "B": [1, 2], }, "onRecord": { "id": 1, "A": ["L", "a", "b"], "B": 1 }, } }; async function getData(shown: number) { await driver.findContentWait('#data', `"shown": ${shown}`, 1000); const data = await driver.find('#data').getText(); const result = JSON.parse(data); assert.equal(result.shown, shown); delete result.shown; return result; } await inFrame(async () => { await gu.waitToPass(async () => { const parsed = await getData(12); assert.deepEqual(parsed, expected); }, 1000); }); // Change the access level away from 'full'. await setAccess("read table"); await gu.waitForServer(); await inFrame(async () => { // onRecord(s) with custom includeColumns without full access will fail // with an error that we can't catch and display, // so only wait for 10 results instead of 12. const parsed = await getData(10); // The default options don't require full access, so the result is the same. assert.deepEqual(parsed.default, expected.default); // The alternative options all set includeColumns to 'normal' or 'all', // which requires full access. assert.deepEqual(parsed.options, { "fetchSelectedTable": "Error: Setting includeColumns to all requires full access. Current access level is read table", "fetchSelectedRecord": "Error: Setting includeColumns to normal requires full access. Current access level is read table", "viewApiFetchSelectedTable": "Error: Setting includeColumns to all requires full access. Current access level is read table", "viewApiFetchSelectedRecord": "Error: Setting includeColumns to normal requires full access. Current access level is read table" }); }); }); }); async function inFrame(op: () => Promise) { await driver.switchTo().frame(driver.find("iframe")); await op(); await driver.switchTo().defaultContent(); }