diff --git a/shared/js/background/background.js b/shared/js/background/background.js index eda6b256d..4c6600cad 100644 --- a/shared/js/background/background.js +++ b/shared/js/background/background.js @@ -29,6 +29,7 @@ import DebuggerConnection from './components/debugger-connection'; import Devtools from './components/devtools'; import DNRListeners from './components/dnr-listeners'; import RemoteConfig from './components/remote-config'; +import DashboardMessaging from './components/dashboard-messaging'; import initDebugBuild from './devbuild'; import initReloader from './devbuild-reloader'; import tabManager from './tab-manager'; @@ -51,14 +52,17 @@ const remoteConfig = new RemoteConfig({ settings }); const abnMetrics = BUILD_TARGET !== 'firefox' ? new AbnExperimentMetrics({ remoteConfig }) : null; const tds = new TDSStorage({ settings, remoteConfig, abnMetrics }); const devtools = new Devtools({ tds }); +const dashboardMessaging = new DashboardMessaging({ settings, tds, tabManager }); /** * @type {{ * autofill: EmailAutofill; + * dashboardMessaging: DashboardMessaging * omnibox: OmniboxSearch; * fireButton?: FireButton; * internalUser: InternalUserDetector; * tds: TDSStorage; * tabTracking: TabTracker; + * toggleReports: ToggleReports; * trackers: TrackersGlobal; * remoteConfig: RemoteConfig; * abnMetrics: AbnExperimentMetrics?; @@ -67,11 +71,12 @@ const devtools = new Devtools({ tds }); */ const components = { autofill: new EmailAutofill({ settings }), + dashboardMessaging, omnibox: new OmniboxSearch(), internalUser: new InternalUserDetector({ settings }), tabTracking: new TabTracker({ tabManager, devtools }), tds, - toggleReports: new ToggleReports(), + toggleReports: new ToggleReports({ dashboardMessaging }), trackers: new TrackersGlobal({ tds }), debugger: new DebuggerConnection({ tds, devtools }), devtools, diff --git a/shared/js/background/broken-site-report.js b/shared/js/background/broken-site-report.js index 69532f952..270626ce0 100644 --- a/shared/js/background/broken-site-report.js +++ b/shared/js/background/broken-site-report.js @@ -10,15 +10,12 @@ * @typedef {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').DataItemId} DisclosureParamId */ -const browser = require('webextension-polyfill'); const load = require('./load'); const browserWrapper = require('./wrapper'); const settings = require('./settings'); const parseUserAgentString = require('../shared-utils/parse-user-agent-string'); const { getCurrentTab, getURLWithoutQueryString } = require('./utils'); const { getURL } = require('./pixels'); -const tdsStorage = require('./storage/tds').default; -const tabManager = require('./tab-manager'); const maxPixelLength = 7000; /** @@ -307,45 +304,6 @@ export async function breakageReportForTab({ return fire(pixelName, brokenSiteParams.toString()); } -/** - * Attempt to send a breakage report for the currently focused tab. - * - * @param {Object} arg - * @prop {string} pixelName - * @prop {import("./classes/tab") | undefined} arg.currentTab - * @prop {string | undefined} arg.category - * @prop {string | undefined} arg.description - * @prop {string | undefined} arg.reportFlow - * String detailing the UI flow that this breakage report came from. - */ -export async function sendBreakageReportForCurrentTab({ pixelName, currentTab, category, description, reportFlow }) { - await settings.ready(); - await tdsStorage.ready('config'); - - const tab = currentTab || (await tabManager.getOrRestoreCurrentTab()); - if (!tab) { - return; - } - - const pageParams = (await browser.tabs.sendMessage(tab.id, { getBreakagePageParams: true })) || {}; - - const tds = settings.getSetting('tds-etag'); - const remoteConfigEtag = settings.getSetting('config-etag'); - const remoteConfigVersion = tdsStorage.config.version; - - return await breakageReportForTab({ - pixelName, - tab, - tds, - remoteConfigEtag, - remoteConfigVersion, - category, - description, - pageParams, - reportFlow, - }); -} - /** * Returns the breakage report details as expected by the * "getBreakageFormOptions" and "getToggleReportOptions" messages. diff --git a/shared/js/background/components/dashboard-messaging.js b/shared/js/background/components/dashboard-messaging.js new file mode 100644 index 000000000..c57c602ec --- /dev/null +++ b/shared/js/background/components/dashboard-messaging.js @@ -0,0 +1,118 @@ +import browser from 'webextension-polyfill'; +import { breakageReportForTab, getDisclosureDetails } from '../broken-site-report'; +import { dashboardDataFromTab } from '../classes/privacy-dashboard-data'; +import { registerMessageHandler } from '../message-handlers'; +import { getCurrentTab } from '../utils'; +import { isFireButtonEnabled } from './fire-button'; + +/** + * Message handlers for communication from the dashboard to the extension background. + * + * Note, handlers are split over multiple components, and some are not yet encapsulated in a component. + * + * Implemented in this component: + * - getBreakageFormOptions + * - getPrivacyDashboardData + * - submitBrokenSiteReport + * + * FireButton component: + * - doBurn + * - getBurnOptions + * - setBurnDefaultOption + * + * EmailAutofill component: + * - refreshAlias + * + * ToggleReports component: + * - getToggleReportOptions + * - rejectToggleReport + * - sendToggleReport + * - seeWhatIsSent + * + * Static message handlers: + * - openOptions + * - search + * - setLists + * + * See https://duckduckgo.github.io/privacy-dashboard/modules/Browser_Extensions_integration.html + */ +export default class DashboardMessaging { + /** + * @param {{ + * settings: import('../settings.js'); + * tds: import('./tds').default; + * tabManager: import('../tab-manager.js'); + * }} args + */ + constructor({ settings, tds, tabManager }) { + this.settings = settings; + this.tds = tds; + this.tabManager = tabManager; + + registerMessageHandler('submitBrokenSiteReport', (report) => this.submitBrokenSiteReport(report)); + registerMessageHandler('getPrivacyDashboardData', this.getPrivacyDashboardData.bind(this)); + registerMessageHandler('getBreakageFormOptions', getDisclosureDetails); + } + + /** + * Only the dashboard sends this message, so we import the types from there. + * @param {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').BreakageReportRequest} breakageReport + * @param {string} [pixelName] + * @param {string} [reportFlow] + * @returns {Promise} + */ + async submitBrokenSiteReport(breakageReport, pixelName = 'epbf', reportFlow = undefined) { + // wait for config and TDS so we can get etags and config version + await Promise.all([this.tds.remoteConfig.ready, this.tds.tds.ready]); + const { category, description } = breakageReport; + const tab = await this.tabManager.getOrRestoreCurrentTab(); + if (!tab) { + return; + } + const pageParams = (await browser.tabs.sendMessage(tab.id, { getBreakagePageParams: true })) || {}; + const tds = this.tds.tds.etag; + const remoteConfigEtag = this.tds.remoteConfig.etag; + const remoteConfigVersion = this.tds.remoteConfig.config?.version; + return breakageReportForTab({ + pixelName, + tab, + tds, + remoteConfigEtag, + remoteConfigVersion, + category, + description, + pageParams, + reportFlow, + }); + } + + /** + * This message is here to ensure the privacy dashboard can render + * from a single call to the extension. + * + * Currently, it will collect data for the current tab and email protection + * user data. + */ + async getPrivacyDashboardData(options) { + let { tabId } = options; + if (tabId === null) { + const currentTab = await getCurrentTab(); + if (!currentTab?.id) { + throw new Error('could not get the current tab...'); + } + tabId = currentTab?.id; + } + + // Await for storage to be ready; this happens on service worker closing mostly. + await this.settings.ready(); + await this.tds.config.ready; + + const tab = await this.tabManager.getOrRestoreTab(tabId); + if (!tab) throw new Error('unreachable - cannot access current tab with ID ' + tabId); + const userData = this.settings.getSetting('userData'); + const fireButtonData = { + enabled: isFireButtonEnabled, + }; + return dashboardDataFromTab(tab, userData, fireButtonData); + } +} diff --git a/shared/js/background/components/toggle-reports.js b/shared/js/background/components/toggle-reports.js index 908a49810..490bfd71e 100644 --- a/shared/js/background/components/toggle-reports.js +++ b/shared/js/background/components/toggle-reports.js @@ -3,7 +3,7 @@ import { registerMessageHandler } from '../message-handlers'; import { postPopupMessage } from '../popup-messaging'; import settings from '../settings'; import { getFeatureSettings, reloadCurrentTab, resolveAfterDelay } from '../utils'; -import { getDisclosureDetails, sendBreakageReportForCurrentTab } from '../broken-site-report'; +import { getDisclosureDetails } from '../broken-site-report'; import { createAlarm } from '../wrapper'; import tabManager from '../tab-manager'; @@ -23,7 +23,14 @@ import tabManager from '../tab-manager'; export default class ToggleReports { static ALARM_NAME = 'toggleReportsClearExpired'; - constructor() { + /** + * + * @param {{ + * dashboardMessaging: import('./dashboard-messaging').default + * }} args + */ + constructor({ dashboardMessaging }) { + this.dashboardMessaging = dashboardMessaging; this.onDisconnect = this.toggleReportFinished.bind(this, false); registerMessageHandler('getToggleReportOptions', (_, sender) => this.toggleReportStarted(sender)); @@ -81,10 +88,11 @@ export default class ToggleReports { try { // Send the breakage report before reloading the page, to ensure // the correct page details are sent with the report. - await sendBreakageReportForCurrentTab({ - pixelName: 'protection-toggled-off-breakage-report', - reportFlow: 'on_protections_off_dashboard_main', - }); + await this.dashboardMessaging.submitBrokenSiteReport( + {}, + 'protection-toggled-off-breakage-report', + 'on_protections_off_dashboard_main', + ); } catch (e) { // Catch this, mostly to ensure the page is still reloaded if // sending the breakage report fails. diff --git a/shared/js/background/message-handlers.js b/shared/js/background/message-handlers.js index c4eb272f9..a88dc7586 100644 --- a/shared/js/background/message-handlers.js +++ b/shared/js/background/message-handlers.js @@ -1,13 +1,10 @@ import browser from 'webextension-polyfill'; -import { dashboardDataFromTab } from './classes/privacy-dashboard-data'; -import { getDisclosureDetails, sendBreakageReportForCurrentTab } from './broken-site-report'; import parseUserAgentString from '../shared-utils/parse-user-agent-string'; import { getExtensionURL } from './wrapper'; import { isFeatureEnabled, reloadCurrentTab } from './utils'; import { ensureClickToLoadRuleActionDisabled } from './dnr-click-to-load'; import tdsStorage from './storage/tds'; import { getArgumentsObject } from './helpers/arguments-object'; -import { isFireButtonEnabled } from './components/fire-button'; import { postPopupMessage } from './popup-messaging'; import ToggleReports from './components/toggle-reports'; const utils = require('./utils'); @@ -99,47 +96,6 @@ export function openOptions() { } } -/** - * Only the dashboard sends this message, so we import the types from there. - * @param {import('@duckduckgo/privacy-dashboard/schema/__generated__/schema.types').BreakageReportRequest} breakageReport - * @returns {Promise} - */ -export function submitBrokenSiteReport(breakageReport) { - const pixelName = 'epbf'; - const { category, description } = breakageReport; - return sendBreakageReportForCurrentTab({ pixelName, category, description }); -} - -/** - * This message is here to ensure the privacy dashboard can render - * from a single call to the extension. - * - * Currently, it will collect data for the current tab and email protection - * user data. - */ -export async function getPrivacyDashboardData(options) { - let { tabId } = options; - if (tabId === null) { - const currentTab = await utils.getCurrentTab(); - if (!currentTab?.id) { - throw new Error('could not get the current tab...'); - } - tabId = currentTab?.id; - } - - // Await for storage to be ready; this happens on service worker closing mostly. - await settings.ready(); - await tdsStorage.ready('config'); - - const tab = await tabManager.getOrRestoreTab(tabId); - if (!tab) throw new Error('unreachable - cannot access current tab with ID ' + tabId); - const userData = settings.getSetting('userData'); - const fireButtonData = { - enabled: isFireButtonEnabled, - }; - return dashboardDataFromTab(tab, userData, fireButtonData); -} - export function getTopBlockedByPages(options) { return Companies.getTopBlockedByPages(options); } @@ -323,9 +279,6 @@ export function addDebugFlag(message, sender, req) { * @param {(options: any, sender: any, req: any) => any} func */ export function registerMessageHandler(name, func) { - if (messageHandlers[name]) { - throw new Error(`Attempt to re-register existing message handler ${name}`); - } messageHandlers[name] = func; } @@ -344,9 +297,6 @@ const messageHandlers = { allowlistOptIn, getBrowser, openOptions, - submitBrokenSiteReport, - getBreakageFormOptions: getDisclosureDetails, - getPrivacyDashboardData, getTopBlockedByPages, getClickToLoadState, getYouTubeVideoDetails, diff --git a/shared/js/background/storage/tds.js b/shared/js/background/storage/tds.js index 0168ec480..bbbe562aa 100644 --- a/shared/js/background/storage/tds.js +++ b/shared/js/background/storage/tds.js @@ -15,6 +15,7 @@ export default { _config: { features: {} }, _tds: { entities: {}, trackers: {}, domains: {}, cnames: {} }, _surrogates: '', + /** @type {import('@duckduckgo/privacy-configuration/schema/config').GenericV4Config} */ get config() { return globalThis.components?.remoteConfig.config || this._config; }, @@ -60,7 +61,7 @@ export default { return Promise.resolve(); } if (configName && listNames.includes(configName)) { - return tdsStorage[configName].ready; + return tdsStorage[configName].allLoadingFinished; } return Promise.all(listNames.map((n) => tdsStorage[n].ready)); }, diff --git a/unit-test/background/abn-framework.js b/unit-test/background/abn-framework.js index e94f7528e..7d85e797a 100644 --- a/unit-test/background/abn-framework.js +++ b/unit-test/background/abn-framework.js @@ -7,23 +7,10 @@ import load from '../../shared/js/background/load'; import commonParams from '../../pixel-definitions/common_params.json'; import commonSuffixes from '../../pixel-definitions/common_suffixes.json'; import experimentPixels from '../../pixel-definitions/pixels/experiments.json'; +import { MockSettings } from '../helpers/mocks'; const ONE_HOUR_MS = 1000 * 60 * 60; -class MockSettings { - constructor() { - this.mockSettingData = new Map(); - this.ready = () => Promise.resolve(); - } - - getSetting(key) { - return structuredClone(this.mockSettingData.get(key)); - } - updateSetting(key, value) { - this.mockSettingData.set(key, value); - } -} - function constructMockComponents() { // clear message handlers to prevent conflict when registering Object.keys(messageHandlers).forEach((k) => delete messageHandlers[k]); diff --git a/unit-test/background/dashboard-messaging.js b/unit-test/background/dashboard-messaging.js new file mode 100644 index 000000000..f2023f29a --- /dev/null +++ b/unit-test/background/dashboard-messaging.js @@ -0,0 +1,124 @@ +import browser from 'webextension-polyfill'; +import load from '../../shared/js/background/load'; +import tabManager from '../../shared/js/background/tab-manager'; +import DashboardMessaging from '../../shared/js/background/components/dashboard-messaging'; +import { MockSettings, mockTdsStorage } from '../helpers/mocks'; +import { _formatPixelRequestForTesting } from '../../shared/js/shared-utils/pixels'; + +const defaultBrokenSitePixelParams = { + upgradedHttps: 'false', + urlParametersRemoved: 'false', + ctlYouTube: 'false', + ctlFacebookPlaceholderShown: 'false', + ctlFacebookLogin: 'false', + performanceWarning: 'false', + userRefreshCount: '0', + jsPerformance: 'undefined', + locale: 'en-US', + errorDescriptions: '[]', + openerContext: 'external', + extensionVersion: '1234.56', + ignoreRequests: '', + blockedTrackers: '', + surrogates: '', + noActionRequests: '', + adAttributionRequests: '', + ignoredByUserRequests: '', +}; + +describe('DashboardMessaging component', () => { + describe('submitBrokenSiteReport', () => { + let currentTabDetails = null; + const actualSentReports = []; + /** @type {DashboardMessaging} */ + let dashboardMessaging = null; + /** @type {import('../../shared/js/background/components/tds').default} */ + let tds = null; + + beforeEach(() => { + currentTabDetails = null; + actualSentReports.length = 0; + spyOn(browser.tabs, 'sendMessage').and.callFake((tabId, message) => { + if (message.getBreakagePageParams) { + return Promise.resolve({}); + } + }); + spyOn(load, 'url').and.callFake((url) => { + const pixel = _formatPixelRequestForTesting(url); + if (pixel?.name?.startsWith('epbf') || pixel?.name?.startsWith('protection-toggled-off-breakage-report')) { + actualSentReports.push(pixel); + } + }); + spyOn(browser.tabs, 'query').and.callFake(() => { + const result = []; + + if (currentTabDetails) { + result.push(currentTabDetails); + } + + return Promise.resolve(result); + }); + const settings = new MockSettings(); + tds = mockTdsStorage(settings); + dashboardMessaging = new DashboardMessaging({ + settings, + tds, + tabManager, + }); + }); + + it('sends a broken site report pixel with provide category and description', async () => { + currentTabDetails = { + id: 123, + url: 'https://domain.example/path?param=value', + }; + tabManager.create(currentTabDetails); + await dashboardMessaging.submitBrokenSiteReport({ category: 'foo', description: 'ben' }); + expect(actualSentReports).toHaveSize(1); + expect(actualSentReports[0]).toEqual({ + name: 'epbf_chrome', + params: { + category: 'foo', + description: 'ben', + siteUrl: 'https://domain.example/path', + tds: tds.tds.etag, + remoteConfigEtag: tds.remoteConfig.etag, + remoteConfigVersion: `${tds.remoteConfig.config.version}`, + protectionsState: 'false', + ...defaultBrokenSitePixelParams, + }, + }); + }); + + it('does not send a pixel if there is no active tab', async () => { + await dashboardMessaging.submitBrokenSiteReport({ category: 'foo', description: 'ben' }); + expect(actualSentReports).toHaveSize(0); + }); + + it('can send toggle reports', async () => { + currentTabDetails = { + id: 123, + url: 'https://domain2.example/path?param=value', + }; + tabManager.create(currentTabDetails); + await dashboardMessaging.submitBrokenSiteReport( + {}, + 'protection-toggled-off-breakage-report', + 'on_protections_off_dashboard_main', + ); + expect(actualSentReports).toHaveSize(1); + expect(actualSentReports[0]).toEqual({ + name: 'protection-toggled-off-breakage-report_chrome', + params: { + siteUrl: 'https://domain2.example/path', + tds: tds.tds.etag, + remoteConfigEtag: tds.remoteConfig.etag, + remoteConfigVersion: `${tds.remoteConfig.config.version}`, + reportFlow: 'on_protections_off_dashboard_main', + // protectionsState is removed from these reports + ...defaultBrokenSitePixelParams, + }, + }); + }); + }); +}); diff --git a/unit-test/background/toggle-reports.js b/unit-test/background/toggle-reports.js index c1cbc44d2..35b2b4240 100644 --- a/unit-test/background/toggle-reports.js +++ b/unit-test/background/toggle-reports.js @@ -1,17 +1,17 @@ import browser from 'webextension-polyfill'; -import load from '../../shared/js/background/load'; import settings from '../../shared/js/background/settings'; import ToggleReports from '../../shared/js/background/components/toggle-reports'; -import { _formatPixelRequestForTesting } from '../../shared/js/shared-utils/pixels'; import tabManager from '../../shared/js/background/tab-manager'; import tdsStorageStub from '../helpers/tds'; describe('ToggleReports', () => { - const actualSentReports = []; let currentTabDetails = null; let currentTimestamp = 1; let settingsStorage = null; - const toggleReports = new ToggleReports(); + /** @type {ToggleReports} */ + let toggleReports = null; + /** @type {jasmine.Spy} */ + let submittedReports; let toggleReportsConfig = null; // Dummy timestamps. @@ -60,21 +60,19 @@ describe('ToggleReports', () => { callback(); }); spyOn(Date, 'now').and.callFake(() => currentTimestamp); - - // Stub the load.url function (used for pixel requests). - spyOn(load, 'url').and.callFake((url) => { - const pixel = _formatPixelRequestForTesting(url); - if (pixel?.name?.startsWith('epbf') || pixel?.name?.startsWith('protection-toggled-off-breakage-report')) { - actualSentReports.push(pixel); - } - }); }); beforeEach(() => { - actualSentReports.length = 0; currentTabDetails = null; currentTimestamp = 1; settingsStorage.clear(); + + toggleReports = new ToggleReports({ + dashboardMessaging: { + submitBrokenSiteReport: () => {}, + }, + }); + submittedReports = spyOn(toggleReports.dashboardMessaging, 'submitBrokenSiteReport'); }); it('toggleReportStarted()', async () => { @@ -116,8 +114,8 @@ describe('ToggleReports', () => { it('toggleReportFinished()', async () => { const expectReports = async (reports, accepted, declined) => { expect(await ToggleReports.countResponses()).toEqual({ accepted, declined }); - expect(actualSentReports).toEqual(reports); - actualSentReports.length = 0; + expect(submittedReports).toHaveBeenCalledTimes(reports.length); + expect(submittedReports.calls.all().map((c) => c.args)).toEqual(reports); }; // Set things up, so that breakage reports can be sent. @@ -148,40 +146,7 @@ describe('ToggleReports', () => { // If user accepts, report should be sent. await toggleReports.toggleReportFinished(true); - await expectReports( - [ - { - name: 'protection-toggled-off-breakage-report_chrome', - params: { - siteUrl: 'https://domain.example/path', - tds: 'tds-etag-123', - remoteConfigEtag: 'config-etag-123', - remoteConfigVersion: '2021.6.7', - upgradedHttps: 'false', - urlParametersRemoved: 'false', - ctlYouTube: 'false', - ctlFacebookPlaceholderShown: 'false', - ctlFacebookLogin: 'false', - performanceWarning: 'false', - userRefreshCount: '0', - jsPerformance: 'undefined', - locale: 'en-US', - errorDescriptions: '[]', - openerContext: 'external', - reportFlow: 'on_protections_off_dashboard_main', - extensionVersion: '1234.56', - ignoreRequests: '', - blockedTrackers: '', - surrogates: '', - noActionRequests: '', - adAttributionRequests: '', - ignoredByUserRequests: '', - }, - }, - ], - 1, - 2, - ); + await expectReports([[{}, 'protection-toggled-off-breakage-report', 'on_protections_off_dashboard_main']], 1, 2); // Tidy up. tabManager.delete(currentTabDetails.id); diff --git a/unit-test/data/extension-config.json b/unit-test/data/extension-config.json index 0624fbf43..21bfefbfb 100644 --- a/unit-test/data/extension-config.json +++ b/unit-test/data/extension-config.json @@ -1,5 +1,5 @@ { - "version": "2021.6.7", + "version": 1738568676812, "readme": "https://github.com/duckduckgo/privacy-configuration", "features": { "adClickAttribution": { diff --git a/unit-test/helpers/mocks.js b/unit-test/helpers/mocks.js new file mode 100644 index 000000000..82dfcf1b9 --- /dev/null +++ b/unit-test/helpers/mocks.js @@ -0,0 +1,51 @@ +import RemoteConfig from '../../shared/js/background/components/remote-config'; +import TDSStorage from '../../shared/js/background/components/tds'; + +export class MockSettings { + constructor() { + this.mockSettingData = new Map(); + this.ready = () => Promise.resolve(); + } + + getSetting(key) { + return structuredClone(this.mockSettingData.get(key)); + } + updateSetting(key, value) { + this.mockSettingData.set(key, value); + } + removeSetting(name) { + this.mockSettingData.delete(name); + } + clearSettings() { + this.mockSettingData.clear(); + } +} + +export function mockTdsStorage(settings) { + // delay settings ready until we have fetch mocking in place + let settingsResolve = null; + const settingsReadyPromise = new Promise((resolve) => { + settingsResolve = resolve; + }); + settings.ready = () => settingsReadyPromise; + const etags = require('../../shared/data/etags.json'); + const remoteConfig = new RemoteConfig({ settings }); + remoteConfig._loadFromURL = () => + Promise.resolve({ + contents: require('./../data/extension-config.json'), + etag: etags['config-etag'], + }); + const tds = new TDSStorage({ settings, remoteConfig }); + tds.tds._loadFromURL = () => + Promise.resolve({ + contents: require('./../data/tds.json'), + etag: etags['current-mv3-tds-etag'], + }); + tds.surrogates._loadFromURL = () => + Promise.resolve({ + contents: require('./../data/surrogates.js').surrogates, + etag: 'surrogates', + }); + settingsResolve(); + return tds; +}