Merge branch 'master' of https://github.com/nasa/openmct into conditionset-errors

This commit is contained in:
Shefali 2025-04-28 11:26:59 -07:00
commit 84d8d8b0e8
30 changed files with 1181 additions and 754 deletions

View File

@ -56,7 +56,7 @@ jobs:
run: npm run cov:e2e:report run: npm run cov:e2e:report
- name: Publish Results to Codecov.io - name: Publish Results to Codecov.io
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v5
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e/lcov.info files: ./coverage/e2e/lcov.info
@ -66,15 +66,19 @@ jobs:
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-test-results
path: test-results path: test-results
overwrite: true
- name: Archive html test results - name: Archive html test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-html-test-results
path: html-test-results path: html-test-results
overwrite: true
- name: Remove pr:e2e:couchdb label (if present) - name: Remove pr:e2e:couchdb label (if present)
if: always() if: always()

View File

@ -38,9 +38,11 @@ jobs:
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-flakefinder-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:flakefinder label (if present) - name: Remove pr:e2e:flakefinder label (if present)
if: always() if: always()

View File

@ -35,9 +35,11 @@ jobs:
- run: npm run test:perf:memory - run: npm run test:perf:memory
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-perf-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:perf label (if present) - name: Remove pr:e2e:perf label (if present)
if: always() if: always()

View File

@ -45,9 +45,11 @@ jobs:
npm run cov:e2e:full:publish npm run cov:e2e:full:publish
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-pr-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e label (if present) - name: Remove pr:e2e label (if present)
if: always() if: always()

View File

@ -1,6 +1,10 @@
codecov: codecov:
require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass
# Disabling annotations for now. They are incorrectly labelling lines as lacking coverage when they are in fact covered by tests.
github_checks:
annotations: false
coverage: coverage:
status: status:
project: project:

View File

@ -2,7 +2,6 @@
module.exports = { module.exports = {
extends: ['plugin:playwright/recommended'], extends: ['plugin:playwright/recommended'],
rules: { rules: {
'playwright/max-nested-describe': ['error', { max: 1 }],
'playwright/expect-expect': 'off' 'playwright/expect-expect': 'off'
}, },
overrides: [ overrides: [

View File

@ -103,25 +103,40 @@ const extendedTest = test.extend({
* Default: `true` * Default: `true`
*/ */
failOnConsoleError: [true, { option: true }], failOnConsoleError: [true, { option: true }],
ignore404s: [[], { option: true }],
/** /**
* Extends the base page class to enable console log error detection. * Extends the base page class to enable console log error detection.
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion} * @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
*/ */
page: async ({ page, failOnConsoleError }, use) => { page: async ({ page, failOnConsoleError, ignore404s }, use) => {
// Capture any console errors during test execution // Capture any console errors during test execution
const messages = []; let messages = [];
page.on('console', (msg) => messages.push(msg)); page.on('console', (msg) => messages.push(msg));
await use(page); await use(page);
if (ignore404s.length > 0) {
messages = messages.filter((msg) => {
let keep = true;
if (msg.text().match(/404 \((Object )?Not Found\)/) !== null) {
keep = ignore404s.every((ignoreRule) => {
return msg.location().url.match(ignoreRule) === null;
});
}
return keep;
});
}
// Assert against console errors during teardown // Assert against console errors during teardown
if (failOnConsoleError) { if (failOnConsoleError) {
messages.forEach((msg) => messages.forEach((msg) => {
// eslint-disable-next-line playwright/no-standalone-expect // eslint-disable-next-line playwright/no-standalone-expect
expect expect
.soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`) .soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`)
.not.toEqual('error') .not.toEqual('error');
); });
} }
} }
}); });

View File

@ -54,8 +54,7 @@ const examplePlanSmall1 = JSON.parse(
const TIME_TO_FROM_COLUMN = 2; const TIME_TO_FROM_COLUMN = 2;
const HEADER_ROW = 0; const HEADER_ROW = 0;
const NUM_COLUMNS = 5; const NUM_COLUMNS = 5;
const FULL_CIRCLE_PATH = const FULL_CIRCLE_PATH = 'M0,-50A50,50,0,1,1,0,50A50,50,0,1,1,0,-50Z';
'M3.061616997868383e-15,-50A50,50,0,1,1,-3.061616997868383e-15,50A50,50,0,1,1,3.061616997868383e-15,-50Z';
/** /**
* The regular expression used to parse the countdown string. * The regular expression used to parse the countdown string.

View File

@ -24,7 +24,9 @@ import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createPlanFromJSON, createPlanFromJSON,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
setFixedIndependentTimeConductorBounds setFixedIndependentTimeConductorBounds,
setFixedTimeMode,
setTimeConductorBounds
} from '../../../appActions.js'; } from '../../../appActions.js';
import { expect, test } from '../../../pluginFixtures.js'; import { expect, test } from '../../../pluginFixtures.js';
@ -74,21 +76,14 @@ const testPlan = {
}; };
test.describe('Time Strip', () => { test.describe('Time Strip', () => {
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({ let timestrip;
page let plan;
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5627'
});
// Constant locators
const activityBounds = page.locator('.activity-bounds');
test.beforeEach(async ({ page }) => {
// Goto baseURL // Goto baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
const timestrip = await test.step('Create a Time Strip', async () => { timestrip = await test.step('Create a Time Strip', async () => {
const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' }); const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
const objectName = await page.locator('.l-browse-bar__object-name').innerText(); const objectName = await page.locator('.l-browse-bar__object-name').innerText();
expect(objectName).toBe(createdTimeStrip.name); expect(objectName).toBe(createdTimeStrip.name);
@ -96,7 +91,7 @@ test.describe('Time Strip', () => {
return createdTimeStrip; return createdTimeStrip;
}); });
const plan = await test.step('Create a Plan and add it to the timestrip', async () => { plan = await test.step('Create a Plan and add it to the timestrip', async () => {
const createdPlan = await createPlanFromJSON(page, { const createdPlan = await createPlanFromJSON(page, {
name: 'Test Plan', name: 'Test Plan',
json: testPlan json: testPlan
@ -110,6 +105,22 @@ test.describe('Time Strip', () => {
.dragTo(page.getByLabel('Object View')); .dragTo(page.getByLabel('Object View'));
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
return createdPlan;
});
});
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({
page
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5627'
});
// Constant locators
const activityBounds = page.locator('.activity-bounds');
await test.step('Set time strip to fixed timespan mode and verify activities', async () => {
const startBound = testPlan.TEST_GROUP[0].start; const startBound = testPlan.TEST_GROUP[0].start;
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
@ -119,8 +130,6 @@ test.describe('Time Strip', () => {
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.locator('.activity-bounds').count(); const eventCount = await page.locator('.activity-bounds').count();
expect(eventCount).toEqual(testPlan.TEST_GROUP.length); expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
return createdPlan;
}); });
await test.step('TimeStrip can use the Independent Time Conductor', async () => { await test.step('TimeStrip can use the Independent Time Conductor', async () => {
@ -177,4 +186,48 @@ test.describe('Time Strip', () => {
expect(await activityBounds.count()).toEqual(1); expect(await activityBounds.count()).toEqual(1);
}); });
}); });
test('Time strip now line', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7817'
});
await test.step('Is displayed in realtime mode', async () => {
await expect(page.getByLabel('Now Marker')).toBeVisible();
});
await test.step('Is hidden when out of bounds of the time axis', async () => {
// Switch to fixed timespan mode
await setFixedTimeMode(page);
// Get the end bounds
const endBounds = await page.getByLabel('End bounds').textContent();
// Add 2 minutes to end bound datetime and use it as the new end time
let endTimeStamp = new Date(endBounds);
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() + 2);
const endDate = endTimeStamp.toISOString().split('T')[0];
const milliseconds = endTimeStamp.getMilliseconds();
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
// Subtract 1 minute from the end bound and use it as the new start time
let startTimeStamp = new Date(endBounds);
startTimeStamp.setUTCMinutes(startTimeStamp.getUTCMinutes() + 1);
const startDate = startTimeStamp.toISOString().split('T')[0];
const startMilliseconds = startTimeStamp.getMilliseconds();
const startTime = startTimeStamp
.toISOString()
.split('T')[1]
.replace(`.${startMilliseconds}Z`, '');
// Set fixed timespan mode to the future so that "now" is out of bounds.
await setTimeConductorBounds(page, {
startDate,
endDate,
startTime,
endTime
});
await expect(page.getByLabel('Now Marker')).toBeHidden();
});
});
}); });

View File

@ -108,4 +108,42 @@ test.describe('Plot Controls', () => {
// Expect before and after plot points to match // Expect before and after plot points to match
await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait); await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait);
}); });
/*
Test to verify that switching a plot's time context from global to
its own independent time context and then back to global context works correctly.
After switching from fixed time mode (ITC) to real time mode (global context),
the pause control for the plot should be available, indicating that it is following the right context.
*/
test('Plots follow the right time context', async ({ page }) => {
// Set global time conductor to real-time mode
await setRealTimeMode(page);
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since global time conductor is in Real time mode.
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
// Toggle independent time conductor ON
await page.getByLabel('Enable Independent Time Conductor').click();
// Bring up the independent time conductor popup and switch to fixed time mode
await page.getByLabel('Independent Time Conductor Settings').click();
await page.getByLabel('Independent Time Conductor Mode Menu').click();
await page.getByRole('menuitem', { name: /Fixed Timespan/ }).click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is no longer visible since the plot is following the independent time context
await expect(page.getByTitle('Pause incoming real-time data')).toBeHidden();
// Toggle independent time conductor OFF - Note that the global time conductor is still in Real time mode
await page.getByLabel('Disable Independent Time Conductor').click();
// hover over plot for plot controls
await page.getByLabel('Plot Canvas').hover();
// Ensure pause control is visible since the global time conductor is in real time mode
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
});
}); });

View File

@ -31,6 +31,8 @@ import { expect, test } from '../../pluginFixtures.js';
test.describe('Grand Search', () => { test.describe('Grand Search', () => {
let grandSearchInput; let grandSearchInput;
test.use({ ignore404s: [/_design\/object_names\/_view\/object_names$/] });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
grandSearchInput = page grandSearchInput = page
.getByLabel('OpenMCT Search') .getByLabel('OpenMCT Search')
@ -191,7 +193,88 @@ test.describe('Grand Search', () => {
await expect(searchResults).toContainText(folderName); await expect(searchResults).toContainText(folderName);
}); });
test.describe('Search will test for the presence of the object_names index, and', () => {
test('use index if available @couchdb @network', async ({ page }) => {
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isObjectNamesUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isObjectNamesRequest && isPostRequest) {
isObjectNamesUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
expect(isObjectNamesViewAvailable).toBe(true);
expect(isObjectNamesUsedForSearch).toBe(true);
});
test('fall-back on base index if index not available @couchdb @network', async ({ page }) => {
await page.route('**/_view/object_names', (route) => {
route.fulfill({
status: 404
});
});
await createObjectsForSearch(page);
let isObjectNamesViewAvailable = false;
let isFindUsedForSearch = false;
page.on('request', async (request) => {
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isObjectNamesRequest && isHeadRequest) {
const response = await request.response();
isObjectNamesViewAvailable = response.status() === 200;
}
});
page.on('request', (request) => {
const isFindRequest = request.url().endsWith('_find');
const isPostRequest = request.method().toLowerCase() === 'post';
if (isFindRequest && isPostRequest) {
isFindUsedForSearch = true;
}
});
// Full search for object
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
// Wait for search to finish
await waitForSearchCompletion(page);
console.info(
`isObjectNamesViewAvailable: ${isObjectNamesViewAvailable} | isFindUsedForSearch: ${isFindUsedForSearch}`
);
expect(isObjectNamesViewAvailable).toBe(false);
expect(isFindUsedForSearch).toBe(true);
});
});
test('Search results are debounced @couchdb @network', async ({ page }) => { test('Search results are debounced @couchdb @network', async ({ page }) => {
// Unfortunately 404s are always logged to the JavaScript console and can't be suppressed
// A 404 is now thrown when we test for the presence of the object names view used by search.
test.info().annotations.push({ test.info().annotations.push({
type: 'issue', type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6179' description: 'https://github.com/nasa/openmct/issues/6179'
@ -199,11 +282,17 @@ test.describe('Grand Search', () => {
await createObjectsForSearch(page); await createObjectsForSearch(page);
let networkRequests = []; let networkRequests = [];
page.on('request', (request) => { page.on('request', (request) => {
const searchRequest = const isSearchRequest =
request.url().endsWith('_find') || request.url().includes('by_keystring'); request.url().endsWith('object_names') ||
const fetchRequest = request.resourceType() === 'fetch'; request.url().endsWith('_find') ||
if (searchRequest && fetchRequest) { request.url().includes('by_keystring');
const isFetchRequest = request.resourceType() === 'fetch';
// CouchDB search results in a one-time head request to test for the presence of an index.
const isHeadRequest = request.method().toLowerCase() === 'head';
if (isSearchRequest && isFetchRequest && !isHeadRequest) {
networkRequests.push(request); networkRequests.push(request);
} }
}); });

View File

@ -213,7 +213,6 @@ test.describe('Navigation memory leak is not detected in', () => {
page, page,
'example-imagery-memory-leak-test' 'example-imagery-memory-leak-test'
); );
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected. // If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -317,6 +316,12 @@ test.describe('Navigation memory leak is not detected in', () => {
// Manually invoke the garbage collector once all references are removed. // Manually invoke the garbage collector once all references are removed.
window.gc(); window.gc();
window.gc();
window.gc();
setTimeout(() => {
window.gc();
}, 1000);
return gcPromise; return gcPromise;
}); });

1260
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,25 +16,25 @@
], ],
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "7.23.3", "@babel/eslint-parser": "7.23.3",
"@braintree/sanitize-url": "6.0.4", "@braintree/sanitize-url": "7.1.1",
"@types/d3-axis": "3.0.6", "@types/d3-axis": "3.0.6",
"@types/d3-scale": "4.0.8", "@types/d3-scale": "4.0.8",
"@types/d3-selection": "3.0.10", "@types/d3-selection": "3.0.10",
"@types/d3-shape": "3.0.0", "@types/d3-shape": "3.1.7",
"@types/eventemitter3": "1.2.0", "@types/eventemitter3": "1.2.0",
"@types/jasmine": "5.1.2", "@types/jasmine": "5.1.2",
"@types/lodash": "4.17.0", "@types/lodash": "4.17.0",
"@vue/compiler-sfc": "3.4.3", "@vue/compiler-sfc": "3.4.3",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1", "babel-plugin-istanbul": "7.0.0",
"comma-separated-values": "3.6.4", "comma-separated-values": "3.6.4",
"copy-webpack-plugin": "12.0.2", "copy-webpack-plugin": "13.0.0",
"cspell": "7.3.8", "cspell": "7.3.8",
"css-loader": "6.10.0", "css-loader": "6.10.0",
"d3-axis": "3.0.0", "d3-axis": "3.0.0",
"d3-scale": "4.0.2", "d3-scale": "4.0.2",
"d3-selection": "3.0.0", "d3-selection": "3.0.0",
"d3-shape": "3.0.0", "d3-shape": "3.2.0",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-compat": "4.2.0", "eslint-plugin-compat": "4.2.0",
@ -51,7 +51,7 @@
"git-rev-sync": "3.0.2", "git-rev-sync": "3.0.2",
"html2canvas": "1.4.1", "html2canvas": "1.4.1",
"imports-loader": "5.0.0", "imports-loader": "5.0.0",
"jasmine-core": "5.1.1", "jasmine-core": "5.6.0",
"karma": "6.4.2", "karma": "6.4.2",
"karma-chrome-launcher": "3.2.0", "karma-chrome-launcher": "3.2.0",
"karma-cli": "2.0.0", "karma-cli": "2.0.0",
@ -64,14 +64,14 @@
"karma-webpack": "5.0.1", "karma-webpack": "5.0.1",
"location-bar": "3.0.1", "location-bar": "3.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "12.0.0", "marked": "15.0.7",
"mini-css-extract-plugin": "2.7.6", "mini-css-extract-plugin": "2.9.2",
"moment": "2.30.1", "moment": "2.30.1",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
"nano": "10.1.4", "nano": "10.1.4",
"npm-run-all2": "6.1.2", "npm-run-all2": "7.0.2",
"nyc": "15.1.0", "nyc": "17.1.0",
"painterro": "1.2.87", "painterro": "1.2.87",
"plotly.js-basic-dist-min": "2.29.1", "plotly.js-basic-dist-min": "2.29.1",
"plotly.js-gl2d-dist-min": "2.20.0", "plotly.js-gl2d-dist-min": "2.20.0",
@ -79,21 +79,21 @@
"prettier-eslint": "16.3.0", "prettier-eslint": "16.3.0",
"printj": "1.3.1", "printj": "1.3.1",
"resolve-url-loader": "5.0.0", "resolve-url-loader": "5.0.0",
"sanitize-html": "2.12.1", "sanitize-html": "2.15.0",
"sass": "1.71.1", "sass": "1.71.1",
"sass-loader": "14.1.1", "sass-loader": "14.1.1",
"style-loader": "3.3.3", "style-loader": "4.0.0",
"terser-webpack-plugin": "5.3.9", "terser-webpack-plugin": "5.3.9",
"tiny-emitter": "2.1.0", "tiny-emitter": "2.1.0",
"typescript": "5.3.3", "typescript": "5.3.3",
"uuid": "9.0.1", "uuid": "11.1.0",
"vue": "3.4.24", "vue": "3.4.24",
"vue-eslint-parser": "9.4.2", "vue-eslint-parser": "9.4.2",
"vue-loader": "16.8.3", "vue-loader": "16.8.3",
"webpack": "5.90.3", "webpack": "5.98.0",
"webpack-cli": "5.1.1", "webpack-cli": "5.1.1",
"webpack-dev-server": "5.0.2", "webpack-dev-server": "5.0.2",
"webpack-merge": "5.10.0" "webpack-merge": "6.0.1"
}, },
"scripts": { "scripts": {
"clean": "rm -rf ./dist ./node_modules ./coverage ./html-test-results ./e2e/test-results ./.nyc_output ./e2e/.nyc_output", "clean": "rm -rf ./dist ./node_modules ./coverage ./html-test-results ./e2e/test-results ./.nyc_output ./e2e/.nyc_output",

View File

@ -20,6 +20,8 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { isIdentifier } from '../objects/object-utils';
/** /**
* @typedef {import('openmct').DomainObject} DomainObject * @typedef {import('openmct').DomainObject} DomainObject
*/ */
@ -209,9 +211,15 @@ export default class CompositionCollection {
this.#cleanUpMutables(); this.#cleanUpMutables();
const children = await this.#provider.load(this.domainObject); const children = await this.#provider.load(this.domainObject);
const childObjects = await Promise.all( const childObjects = await Promise.all(
children.map((c) => this.#publicAPI.objects.get(c, abortSignal)) children.map((child) => {
if (isIdentifier(child)) {
return this.#publicAPI.objects.get(child, abortSignal);
} else {
return Promise.resolve(child);
}
})
); );
childObjects.forEach((c) => this.add(c, true)); childObjects.forEach((child) => this.add(child, true));
this.#emit('load'); this.#emit('load');
return childObjects; return childObjects;

View File

@ -96,8 +96,9 @@ export default class CompositionProvider {
* object. * object.
* @param {DomainObject} domainObject the domain object * @param {DomainObject} domainObject the domain object
* for which to load composition * for which to load composition
* @returns {Promise<Identifier[]>} a promise for * @returns {Promise<Identifier[] | DomainObject[]>} a promise for
* the Identifiers in this composition * the Identifiers or Domain Objects in this composition. If Identifiers are returned,
* they will be automatically resolved to domain objects by the API.
*/ */
load(domainObject) { load(domainObject) {
throw new Error('This method must be implemented by a subclass.'); throw new Error('This method must be implemented by a subclass.');

View File

@ -21,7 +21,7 @@
*****************************************************************************/ *****************************************************************************/
import { toRaw } from 'vue'; import { toRaw } from 'vue';
import { makeKeyString } from '../objects/object-utils.js'; import { makeKeyString, parseKeyString } from '../objects/object-utils.js';
import CompositionProvider from './CompositionProvider.js'; import CompositionProvider from './CompositionProvider.js';
/** /**
@ -75,7 +75,11 @@ export default class DefaultCompositionProvider extends CompositionProvider {
* the Identifiers in this composition * the Identifiers in this composition
*/ */
load(domainObject) { load(domainObject) {
return Promise.all(domainObject.composition); const identifiers = domainObject.composition
.filter((idOrKeystring) => idOrKeystring !== null && idOrKeystring !== undefined)
.map((idOrKeystring) => parseKeyString(idOrKeystring));
return Promise.all(identifiers);
} }
/** /**
* Attach listeners for changes to the composition of a given domain object. * Attach listeners for changes to the composition of a given domain object.

View File

@ -27,6 +27,7 @@ import ConflictError from './ConflictError.js';
import InMemorySearchProvider from './InMemorySearchProvider.js'; import InMemorySearchProvider from './InMemorySearchProvider.js';
import InterceptorRegistry from './InterceptorRegistry.js'; import InterceptorRegistry from './InterceptorRegistry.js';
import MutableDomainObject from './MutableDomainObject.js'; import MutableDomainObject from './MutableDomainObject.js';
import { isIdentifier, isKeyString } from './object-utils.js';
import RootObjectProvider from './RootObjectProvider.js'; import RootObjectProvider from './RootObjectProvider.js';
import RootRegistry from './RootRegistry.js'; import RootRegistry from './RootRegistry.js';
import Transaction from './Transaction.js'; import Transaction from './Transaction.js';
@ -742,11 +743,19 @@ export default class ObjectAPI {
* @param {AbortSignal} abortSignal (optional) signal to abort fetch requests * @param {AbortSignal} abortSignal (optional) signal to abort fetch requests
* @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects * @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects
*/ */
async getOriginalPath(identifier, path = [], abortSignal = null) { async getOriginalPath(identifierOrObject, path = [], abortSignal = null) {
const domainObject = await this.get(identifier, abortSignal); let domainObject;
if (isKeyString(identifierOrObject) || isIdentifier(identifierOrObject)) {
domainObject = await this.get(identifierOrObject, abortSignal);
} else {
domainObject = identifierOrObject;
}
if (!domainObject) { if (!domainObject) {
return []; return [];
} }
path.push(domainObject); path.push(domainObject);
const { location } = domainObject; const { location } = domainObject;
if (location && !this.#pathContainsDomainObject(location, path)) { if (location && !this.#pathContainsDomainObject(location, path)) {

View File

@ -359,6 +359,18 @@ class IndependentTimeContext extends TimeContext {
} }
} }
/**
* @returns {boolean}
* @override
*/
isFixed() {
if (this.upstreamTimeContext) {
return this.upstreamTimeContext.isFixed(...arguments);
} else {
return super.isFixed(...arguments);
}
}
/** /**
* @returns {number} * @returns {number}
* @override * @override
@ -400,7 +412,7 @@ class IndependentTimeContext extends TimeContext {
} }
/** /**
* Reset the time context to the global time context * Reset the time context from the global time context
*/ */
resetContext() { resetContext() {
if (this.upstreamTimeContext) { if (this.upstreamTimeContext) {
@ -428,6 +440,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds // Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds()); this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds()); this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from previous time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
} }
/** /**
@ -502,6 +518,10 @@ class IndependentTimeContext extends TimeContext {
// Emit bounds so that views that are changing context get the upstream bounds // Emit bounds so that views that are changing context get the upstream bounds
this.emit('bounds', this.getBounds()); this.emit('bounds', this.getBounds());
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds()); this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
// Also emit the mode in case it's different from the global time context
if (this.getMode()) {
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
}
// now that the view's context is set, tell others to check theirs in case they were following this view's context. // now that the view's context is set, tell others to check theirs in case they were following this view's context.
this.globalTimeContext.emit('refreshContext', viewKey); this.globalTimeContext.emit('refreshContext', viewKey);
} }

View File

@ -23,6 +23,7 @@
import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants'; import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants';
import IndependentTimeContext from '@/api/time/IndependentTimeContext'; import IndependentTimeContext from '@/api/time/IndependentTimeContext';
import { TIME_CONTEXT_EVENTS } from './constants';
import GlobalTimeContext from './GlobalTimeContext.js'; import GlobalTimeContext from './GlobalTimeContext.js';
/** /**
@ -142,7 +143,7 @@ class TimeAPI extends GlobalTimeContext {
addIndependentContext(key, value, clockKey) { addIndependentContext(key, value, clockKey) {
let timeContext = this.getIndependentContext(key); let timeContext = this.getIndependentContext(key);
//stop following upstream time context since the view has it's own //stop following upstream time context since the view has its own
timeContext.resetContext(); timeContext.resetContext();
if (clockKey) { if (clockKey) {
@ -152,6 +153,9 @@ class TimeAPI extends GlobalTimeContext {
timeContext.setMode(FIXED_MODE_KEY, value); timeContext.setMode(FIXED_MODE_KEY, value);
} }
// Also emit the mode in case it's different from the previous time context
timeContext.emit(TIME_CONTEXT_EVENTS.modeChanged, structuredClone(timeContext.getMode()));
// Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context // Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context
this.emit('refreshContext', key); this.emit('refreshContext', key);

View File

@ -24,6 +24,9 @@ export default function (folderName, couchPlugin, searchFilter) {
location: 'ROOT' location: 'ROOT'
}); });
} }
},
search() {
return Promise.resolve([]);
} }
}); });
@ -35,9 +38,17 @@ export default function (folderName, couchPlugin, searchFilter) {
); );
}, },
load() { load() {
return couchProvider.getObjectsByFilter(searchFilter).then((objects) => { let searchResults;
return objects.map((object) => object.identifier);
}); if (searchFilter.viewName !== undefined) {
// Use a view to search, instead of an _all_docs find
searchResults = couchProvider.getObjectsByView(searchFilter);
} else {
// Use the _find endpoint to search _all_docs
searchResults = couchProvider.getObjectsByFilter(searchFilter);
}
return searchResults;
} }
}); });
}; };

View File

@ -344,12 +344,19 @@ export default {
}, },
beforeMount() { beforeMount() {
this.marked = new Marked(); this.marked = new Marked();
this.renderer = new this.marked.Renderer(); this.marked.use({
breaks: true,
extensions: [
{
name: 'link',
renderer: (options) => {
return this.validateLink(options);
}
}
]
});
}, },
mounted() { mounted() {
const originalLinkRenderer = this.renderer.link;
this.renderer.link = this.validateLink.bind(this, originalLinkRenderer);
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400); this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
if (this.$refs.embedsWrapper) { if (this.$refs.embedsWrapper) {
@ -437,10 +444,7 @@ export default {
} }
}, },
convertMarkDownToHtml(text = '') { convertMarkDownToHtml(text = '') {
let markDownHtml = this.marked.parse(text, { let markDownHtml = this.marked.parse(text);
breaks: true,
renderer: this.renderer
});
markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA); markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA);
return markDownHtml; return markDownHtml;
}, },
@ -451,21 +455,19 @@ export default {
this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
}, },
validateLink(originalLinkRenderer, href, title, text) { validateLink(options) {
const { href, text } = options;
try { try {
const domain = new URL(href).hostname; const domain = new URL(href).hostname;
const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => { const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => {
return domain.endsWith(partialDomain); return domain.endsWith(partialDomain);
}); });
if (!urlIsWhitelisted) { if (!urlIsWhitelisted) {
return text; return text;
} }
const linkHtml = originalLinkRenderer.call(this.renderer, href, title, text);
const linkHtmlWithTarget = linkHtml.replace( return `<a class="c-hyperlink" target="_blank" href="${href}">${text}</a>`;
/^<a /,
'<a class="c-hyperlink" target="_blank"'
);
return linkHtmlWithTarget;
} catch (error) { } catch (error) {
// had error parsing this URL, just return the text // had error parsing this URL, just return the text
return text; return text;

View File

@ -434,16 +434,69 @@ class CouchObjectProvider {
return Promise.resolve([]); return Promise.resolve([]);
} }
async getObjectsByView({ designDoc, viewName, keysToSearch }, abortSignal) { async isViewDefined(designDoc, viewName) {
const stringifiedKeys = JSON.stringify(keysToSearch); const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const url = `${this.url}/_design/${designDoc}/_view/${viewName}?keys=${stringifiedKeys}&include_docs=true`; const response = await fetch(url, {
method: 'HEAD'
});
return response.ok;
}
/**
* @typedef GetObjectByViewOptions
* @property {String} designDoc the name of the design document that the view belongs to
* @property {String} viewName
* @property {Array.<String>} [keysToSearch] a list of discrete view keys to search for. View keys are not object identifiers.
* @property {String} [startKey] limit the search to a range of keys starting with the provided `startKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {String} [endKey] limit the search to a range of keys ending with the provided `endKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
* @property {Number} [limit] limit the number of results returned
* @property {String} [objectIdField] The field (either key or value) to treat as an object key. If provided, include_docs will be set to false in the request, and the field will be used as an object identifier. A bulk request will be used to resolve objects from identifiers
*/
/**
* Return objects based on a call to a view. See https://docs.couchdb.org/en/stable/api/ddoc/views.html.
* @param {GetObjectByViewOptions} options
* @param {AbortSignal} abortSignal
* @returns {Promise<Array.<import('openmct.js').DomainObject>>}
*/
async getObjectsByView(
{ designDoc, viewName, keysToSearch, startKey, endKey, limit, objectIdField },
abortSignal
) {
let stringifiedKeys = JSON.stringify(keysToSearch);
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
const requestBody = {};
let requestBodyString;
if (objectIdField === undefined) {
requestBody.include_docs = true;
}
if (limit !== undefined) {
requestBody.limit = limit;
}
if (startKey !== undefined && endKey !== undefined) {
/* spell-checker: disable */
requestBody.startkey = startKey;
requestBody.endkey = endKey;
requestBodyString = JSON.stringify(requestBody);
requestBodyString = requestBodyString.replace('$START_KEY', startKey);
requestBodyString = requestBodyString.replace('$END_KEY', endKey);
/* spell-checker: enable */
} else {
requestBody.keys = stringifiedKeys;
requestBodyString = JSON.stringify(requestBody);
}
let objectModels = []; let objectModels = [];
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
signal: abortSignal signal: abortSignal,
body: requestBodyString
}); });
if (!response.ok) { if (!response.ok) {
@ -454,6 +507,13 @@ class CouchObjectProvider {
const result = await response.json(); const result = await response.json();
const couchRows = result.rows; const couchRows = result.rows;
if (objectIdField !== undefined) {
const objectIdsToResolve = [];
couchRows.forEach((couchRow) => {
objectIdsToResolve.push(couchRow[objectIdField]);
});
objectModels = Object.values(await this.#bulkGet(objectIdsToResolve), abortSignal);
} else {
couchRows.forEach((couchRow) => { couchRows.forEach((couchRow) => {
const couchDoc = couchRow.doc; const couchDoc = couchRow.doc;
const objectModel = this.#getModel(couchDoc); const objectModel = this.#getModel(couchDoc);
@ -461,6 +521,7 @@ class CouchObjectProvider {
objectModels.push(objectModel); objectModels.push(objectModel);
} }
}); });
}
} catch (error) { } catch (error) {
// do nothing // do nothing
} }

View File

@ -33,7 +33,11 @@ class CouchSearchProvider {
#bulkPromise; #bulkPromise;
#batchIds; #batchIds;
#lastAbortSignal; #lastAbortSignal;
#isSearchByNameViewDefined;
/**
*
* @param {import('./CouchObjectProvider').default} couchObjectProvider
*/
constructor(couchObjectProvider) { constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider; this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
@ -67,7 +71,36 @@ class CouchSearchProvider {
} }
} }
searchForObjects(query, abortSignal) { #isOptimizedSearchByNameSupported() {
let isOptimizedSearchAvailable;
if (this.#isSearchByNameViewDefined === undefined) {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined =
this.couchObjectProvider.isViewDefined('object_names', 'object_names');
} else {
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined;
}
return isOptimizedSearchAvailable;
}
async searchForObjects(query, abortSignal) {
const preparedQuery = query.toLowerCase().trim();
const supportsOptimizedSearchByName = await this.#isOptimizedSearchByNameSupported();
if (supportsOptimizedSearchByName) {
return this.couchObjectProvider.getObjectsByView(
{
designDoc: 'object_names',
viewName: 'object_names',
startKey: preparedQuery,
endKey: preparedQuery + `\ufff0`,
objectIdField: 'value',
limit: 1000
},
abortSignal
);
} else {
const filter = { const filter = {
selector: { selector: {
model: { model: {
@ -77,8 +110,8 @@ class CouchSearchProvider {
} }
} }
}; };
return this.couchObjectProvider.getObjectsByFilter(filter);
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); }
} }
async #deferBatchAnnotationSearch() { async #deferBatchAnnotationSearch() {

View File

@ -373,44 +373,6 @@ describe('the plugin', () => {
expect(requestMethod).toEqual('PUT'); expect(requestMethod).toEqual('PUT');
}); });
}); });
describe('implements server-side search', () => {
let mockPromise;
beforeEach(() => {
mockPromise = Promise.resolve({
body: {
getReader() {
return {
read() {
return Promise.resolve({
done: true,
value: undefined
});
}
};
}
}
});
fetch.and.returnValue(mockPromise);
});
it("using Couch's 'find' endpoint", async () => {
await Promise.all(openmct.objects.search('test'));
const requestUrl = fetch.calls.mostRecent().args[0];
// we only want one call to fetch, not 2!
// see https://github.com/nasa/openmct/issues/4667
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestUrl.endsWith('_find')).toBeTrue();
});
it('and supports search by object name', async () => {
await Promise.all(openmct.objects.search('test'));
const requestPayload = JSON.parse(fetch.calls.mostRecent().args[1].body);
expect(requestPayload).toBeDefined();
expect(requestPayload.selector.model.name.$regex).toEqual('(?i)test');
});
});
}); });
describe('the view', () => { describe('the view', () => {

View File

@ -99,6 +99,48 @@ create_replicator_table() {
add_index_and_views() { add_index_and_views() {
echo "Adding index and views to $OPENMCT_DATABASE_NAME database" echo "Adding index and views to $OPENMCT_DATABASE_NAME database"
# Add object names search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_names/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_names",
"views":{
"object_names":{
"map":"function(doc) { if (doc.model && doc.model.name) { const name = doc.model.name.toLowerCase().trim(); if (name.length > 0) { emit(name, doc._id); const tokens = name.split(/[^a-zA-Z0-9]/); tokens.forEach((token) => { if (token.length > 0) { emit(token, doc._id); } }); } } }"
}
}
}');
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_names"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_names already exists, skipping creation"
else
echo "Unable to create object_names"
echo $response
fi
# Add object types search index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_types/\
--header 'Content-Type: application/json' \
--data '{
"_id":"_design/object_types",
"views":{
"object_types":{
"map":"function(doc) { if (doc.model && doc.model.type) { const type = doc.model.type.toLowerCase().trim(); if (type.length > 0) { emit(type, null); } } }"
}
}
}')
if [[ $response =~ "\"ok\":true" ]]; then
echo "Successfully created object_types"
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
echo "object_types already exists, skipping creation"
else
echo "Unable to create object_types"
echo $response
fi
# Add type_tags_index # Add type_tags_index
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\ response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \

View File

@ -539,6 +539,7 @@ export default {
this.followTimeContext(); this.followTimeContext();
}, },
followTimeContext() { followTimeContext() {
this.updateMode();
this.updateDisplayBounds(this.timeContext.getBounds()); this.updateDisplayBounds(this.timeContext.getBounds());
this.timeContext.on('modeChanged', this.updateMode); this.timeContext.on('modeChanged', this.updateMode);
this.timeContext.on('boundsChanged', this.updateDisplayBounds); this.timeContext.on('boundsChanged', this.updateDisplayBounds);

View File

@ -243,12 +243,20 @@ export default {
this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode); this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode);
}, },
setTimeOptionsClock(clock) { setTimeOptionsClock(clock) {
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
return;
}
this.setTimeOptionsOffsets(); this.setTimeOptionsOffsets();
this.timeOptions.clock = clock.key; this.timeOptions.clock = clock.key;
}, },
setTimeOptionsMode(mode) { setTimeOptionsMode(mode) {
// If the user has persisted any time options, then don't override them with global settings.
if (this.independentTCEnabled) {
this.setTimeOptionsOffsets(); this.setTimeOptionsOffsets();
this.timeOptions.mode = mode; this.timeOptions.mode = mode;
this.isFixed = this.timeOptions.mode === FIXED_MODE_KEY;
}
}, },
setTimeOptionsOffsets() { setTimeOptionsOffsets() {
this.timeOptions.clockOffsets = this.timeOptions.clockOffsets =

View File

@ -21,7 +21,9 @@
--> -->
<template> <template>
<div ref="axisHolder" class="c-timesystem-axis"> <div ref="axisHolder" class="c-timesystem-axis">
<div class="nowMarker" :style="nowMarkerStyle"><span class="icon-arrow-down"></span></div> <div class="nowMarker" :style="nowMarkerStyle" aria-label="Now Marker">
<span class="icon-arrow-down"></span>
</div>
<svg :width="svgWidth" :height="svgHeight"> <svg :width="svgWidth" :height="svgHeight">
<g class="axis" font-size="1.3em" :transform="axisTransform"></g> <g class="axis" font-size="1.3em" :transform="axisTransform"></g>
</svg> </svg>
@ -116,8 +118,10 @@ export default {
this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`; this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`;
const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0; const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0;
this.leftAlignmentOffset = this.alignmentData.leftWidth + leftOffset;
this.alignmentOffset = this.alignmentOffset =
this.alignmentData.leftWidth + leftOffset + this.alignmentData.rightWidth + rightOffset; this.leftAlignmentOffset + this.alignmentData.rightWidth + rightOffset;
this.refresh(); this.refresh();
}, },
deep: true deep: true
@ -175,8 +179,8 @@ export default {
this.nowMarkerStyle.height = this.contentHeight + 'px'; this.nowMarkerStyle.height = this.contentHeight + 'px';
const nowTimeStamp = this.openmct.time.now(); const nowTimeStamp = this.openmct.time.now();
const now = this.xScale(nowTimeStamp); const now = this.xScale(nowTimeStamp);
this.nowMarkerStyle.left = `${now + this.alignmentOffset}px`; this.nowMarkerStyle.left = `${now + this.leftAlignmentOffset}px`;
if (now > this.width) { if (now < 0 || now > this.width) {
nowMarker.classList.add('hidden'); nowMarker.classList.add('hidden');
} }
} }

View File

@ -111,9 +111,8 @@ export default {
return null; return null;
} }
const keyStringForObject = this.openmct.objects.makeKeyString(domainObject.identifier);
const originalPathObjects = await this.openmct.objects.getOriginalPath( const originalPathObjects = await this.openmct.objects.getOriginalPath(
keyStringForObject, domainObject,
[], [],
abortSignal abortSignal
); );