diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index d7647d57a..4291078fd 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -18,7 +18,7 @@ jobs: IOS_MODEL: 'iPhone 17' - HOST_OS: 'macos-15' XCODE_VERSION: '16.4' - IOS_VERSION: '18.4' + IOS_VERSION: '18.5' IOS_MODEL: 'iPhone 16 Plus' - HOST_OS: 'macos-14' XCODE_VERSION: '15.4' @@ -46,6 +46,7 @@ jobs: DEVICE_NAME: ${{matrix.test_targets.IOS_MODEL}} PLATFORM_VERSION: ${{matrix.test_targets.IOS_VERSION}} run: | + xcrun simctl list devices available open -Fn "$(xcode-select -p)/Applications/Simulator.app" udid=$(xcrun simctl list devices available -j | \ node -p "Object.entries(JSON.parse(fs.readFileSync(0)).devices).filter((x) => x[0].includes('$PLATFORM_VERSION'.replace('.', '-'))).reduce((acc, x) => [...acc, ...x[1]], []).find(({name}) => name === '$DEVICE_NAME').udid") diff --git a/lib/appium-ios-device.d.ts b/lib/appium-ios-device.d.ts deleted file mode 100644 index 5aa65bffd..000000000 --- a/lib/appium-ios-device.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module 'appium-ios-device' { - export class Xctest { - constructor(...args: any[]); - start(): Promise; - stop(): void; - } -} diff --git a/lib/index.ts b/lib/index.ts index 4d78ec618..0bb3f0806 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,7 +1,7 @@ export {bundleWDASim} from './check-dependencies'; export {NoSessionProxy} from './no-session-proxy'; export {WebDriverAgent} from './webdriveragent'; -export {WDA_BASE_URL, WDA_RUNNER_BUNDLE_ID, PROJECT_FILE} from './constants'; +export {WDA_BASE_URL, WDA_RUNNER_APP, WDA_RUNNER_BUNDLE_ID, PROJECT_FILE} from './constants'; export {resetTestProcesses, BOOTSTRAP_PATH} from './utils'; export * from './types'; diff --git a/lib/types.ts b/lib/types.ts index e748c3bd5..6778739c1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,6 @@ import {type HTTPHeaders} from '@appium/types'; +import type {Simctl} from 'node-simctl'; +import type {Devicectl} from 'node-devicectl'; // WebDriverAgentLib/Utilities/FBSettings.h export interface WDASettings { @@ -99,9 +101,8 @@ export interface WebDriverAgentArgs { export interface AppleDevice { udid: string; - simctl?: any; - devicectl?: any; - [key: string]: any; + simctl?: Simctl; + devicectl?: Devicectl; } /** diff --git a/lib/webdriveragent.ts b/lib/webdriveragent.ts index b0d763506..750c30a27 100644 --- a/lib/webdriveragent.ts +++ b/lib/webdriveragent.ts @@ -1,7 +1,7 @@ import {waitForCondition} from 'asyncbox'; import path from 'node:path'; import {JWProxy} from '@appium/base-driver'; -import {fs, util, plist} from '@appium/support'; +import {fs, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {log as defaultLogger} from './logger'; import {NoSessionProxy} from './no-session-proxy'; @@ -14,21 +14,19 @@ import { import {XcodeBuild} from './xcodebuild'; import AsyncLock from 'async-lock'; import {exec} from 'teen_process'; -import {bundleWDASim} from './check-dependencies'; import { WDA_RUNNER_BUNDLE_ID, - WDA_RUNNER_APP, WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH, DEFAULT_TEST_BUNDLE_SUFFIX, } from './constants'; -import {Xctest} from 'appium-ios-device'; import {strongbox} from '@appium/strongbox'; import type {WebDriverAgentArgs, AppleDevice} from './types'; +import type {Simctl} from 'node-simctl'; +import type {Devicectl} from 'node-devicectl'; const WDA_LAUNCH_TIMEOUT = 60 * 1000; const WDA_AGENT_PORT = 8100; -const WDA_CF_BUNDLE_NAME = 'WebDriverAgentRunner-Runner'; const SHARED_RESOURCES_GUARD = new AsyncLock(); const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion'; const URL_PROTOCOL_SEPARATOR = '://'; @@ -64,7 +62,6 @@ export class WebDriverAgent { private readonly wdaLaunchTimeout: number; private readonly usePreinstalledWDA?: boolean; private readonly updatedWDABundleIdSuffix: string; - private xctestApiClient?: Xctest | null; private _xcodebuild?: XcodeBuild | null; private _url?: URL; @@ -112,7 +109,6 @@ export class WebDriverAgent { this.wdaLaunchTimeout = args.wdaLaunchTimeout || WDA_LAUNCH_TIMEOUT; this.usePreinstalledWDA = args.usePreinstalledWDA; - this.xctestApiClient = null; this.updatedWDABundleIdSuffix = args.updatedWDABundleIdSuffix ?? DEFAULT_TEST_BUNDLE_SUFFIX; this._xcodebuild = this.canSkipXcodebuild @@ -366,15 +362,14 @@ export class WebDriverAgent { async quit(): Promise { if (this.usePreinstalledWDA) { this.log.info('Stopping the XCTest session'); - if (this.xctestApiClient) { - this.xctestApiClient.stop(); - this.xctestApiClient = null; - } else { - try { + try { + if (this.device.simctl) { await this.device.simctl.terminateApp(this.bundleIdForXctest); - } catch (e: any) { - this.log.warn(e.message); + } else if (this.device.devicectl) { + await this.device.devicectl.terminateApp(this.bundleIdForXctest); } + } catch (e: any) { + this.log.warn(e.message); } } else if (!this.args.webDriverAgentUrl) { this.log.info('Shutting down sub-processes'); @@ -383,8 +378,7 @@ export class WebDriverAgent { } } else { this.log.debug( - 'Do not stop xcodebuild nor XCTest session ' + - 'since the WDA session is managed by outside this driver.', + 'Stopping neither xcodebuild nor XCTest session since WDA lifecycle is not managed by this driver', ); } @@ -415,13 +409,14 @@ export class WebDriverAgent { /** * Reuse running WDA if it has the same bundle id with updatedWDABundleId. * Or reuse it if it has the default id without updatedWDABundleId. - * Uninstall it if the method faces an exception for the above situation. + * + * @returns The WDA URL used for caching on success, or `undefined` if caching was skipped. */ - async setupCaching(): Promise { + async setupCaching(): Promise { const status = await this.getStatus(0); if (!status || !status.build) { this.log.debug('WDA is currently not running. There is nothing to cache'); - return; + return undefined; } const {productBundleIdentifier, upgradedAt} = status.build as any; @@ -432,9 +427,9 @@ export class WebDriverAgent { this.updatedWDABundleId !== productBundleIdentifier ) { this.log.info( - `Will uninstall running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`, + `Will not reuse running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`, ); - return await this.uninstall(); + return undefined; } // for simulator if ( @@ -443,9 +438,9 @@ export class WebDriverAgent { WDA_RUNNER_BUNDLE_ID !== productBundleIdentifier ) { this.log.info( - `Will uninstall running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`, + `Will not reuse running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`, ); - return await this.uninstall(); + return undefined; } const actualUpgradeTimestamp = await getWDAUpgradeTimestamp(); @@ -457,51 +452,21 @@ export class WebDriverAgent { `${actualUpgradeTimestamp}`.toLowerCase() !== `${upgradedAt}`.toLowerCase() ) { this.log.info( - 'Will uninstall running WDA since it has different version in comparison to the one ' + + 'Will not reuse running WDA since it has different version in comparison to the one ' + `which is bundled with appium-xcuitest-driver module (${actualUpgradeTimestamp} != ${upgradedAt})`, ); - return await this.uninstall(); + return undefined; } + const cachedUrl = this.url.href; const message = util.hasValue(productBundleIdentifier) - ? `Will reuse previously cached WDA instance at '${this.url.href}' with '${productBundleIdentifier}'` - : `Will reuse previously cached WDA instance at '${this.url.href}'`; + ? `Will reuse previously cached WDA instance at '${cachedUrl}' with '${productBundleIdentifier}'` + : `Will reuse previously cached WDA instance at '${cachedUrl}'`; this.log.info( `${message}. Set the wdaLocalPort capability to a value different from ${this.url.port} if this is an undesired behavior.`, ); - this.webDriverAgentUrl = this.url.href; - } - - /** - * Quit and uninstall running WDA. - */ - async quitAndUninstall(): Promise { - await this.quit(); - await this.uninstall(); - } - - private async parseBundleId(wdaBundlePath: string): Promise { - const infoPlistPath = path.join(wdaBundlePath, 'Info.plist'); - const infoPlist = (await plist.parsePlist(await fs.readFile(infoPlistPath))) as { - CFBundleIdentifier?: string; - }; - if (!infoPlist.CFBundleIdentifier) { - throw new Error(`Could not find bundle id in '${infoPlistPath}'`); - } - return infoPlist.CFBundleIdentifier; - } - - private async fetchWDABundle(): Promise { - if (!this.derivedDataPath) { - return await bundleWDASim(this.xcodebuild); - } - const wdaBundlePaths = await fs.glob(`${this.derivedDataPath}/**/*${WDA_RUNNER_APP}/`, { - absolute: true, - }); - if (wdaBundlePaths.length === 0) { - throw new Error(`Could not find the WDA bundle in '${this.derivedDataPath}'`); - } - return wdaBundlePaths[0]; + this.webDriverAgentUrl = cachedUrl; + return cachedUrl; } private setupProxies(sessionId: string): void { @@ -549,10 +514,6 @@ export class WebDriverAgent { this.log.info(`Using WDA agent: '${this.agentPath}'`); } - private async isRunning(): Promise { - return !!(await this.getStatus()); - } - /** * Return current running WDA's status like below * { @@ -626,32 +587,6 @@ export class WebDriverAgent { return status; } - /** - * Uninstall WDAs from the test device. - * Over Xcode 11, multiple WDA can be in the device since Xcode 11 generates different WDA. - * Appium does not expect multiple WDAs are running on a device. - */ - private async uninstall(): Promise { - try { - const bundleIds = await this.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME); - if (bundleIds.length === 0) { - this.log.debug('No WDAs on the device.'); - return; - } - - this.log.debug(`Uninstalling WDAs: '${bundleIds}'`); - for (const bundleId of bundleIds) { - await this.device.removeApp(bundleId); - } - } catch (e: any) { - this.log.debug(e); - this.log.warn( - `WebDriverAgent uninstall failed. Perhaps, it is already uninstalled? ` + - `Original error: ${e.message}`, - ); - } - } - private async _cleanupProjectIfFresh(): Promise { if (this.canSkipXcodebuild) { return; @@ -725,9 +660,6 @@ export class WebDriverAgent { * https://github.com/appium/WebDriverAgent/releases * with proper sign for this case. * - * When we implement launching XCTest service via appium-ios-device, - * this implementation can be replaced with it. - * * @param opts launching WDA with devicectl command options. */ private async _launchViaDevicectl( @@ -735,7 +667,10 @@ export class WebDriverAgent { ): Promise { const {env} = opts; - await this.device.devicectl.launchApp(this.bundleIdForXctest, {env, terminateExisting: true}); + await (this.device.devicectl as Devicectl).launchApp(this.bundleIdForXctest, { + env, + terminateExisting: true, + }); } /** @@ -755,19 +690,9 @@ export class WebDriverAgent { } this.log.info('Launching WebDriverAgent on the device without xcodebuild'); if (this.isRealDevice) { - // Current method to launch WDA process can be done via 'xcrun devicectl', - // but it has limitation about the WDA preinstalled package. - // https://github.com/appium/appium/issues/19206#issuecomment-2014182674 - if (this.platformVersion && util.compareVersions(this.platformVersion, '>=', '17.0')) { - await this._launchViaDevicectl({env: xctestEnv}); - } else { - this.xctestApiClient = new Xctest(this.device.udid, this.bundleIdForXctest, null, { - env: xctestEnv, - }); - await this.xctestApiClient.start(); - } + await this._launchViaDevicectl({env: xctestEnv}); } else { - await this.device.simctl.exec('launch', { + await (this.device.simctl as Simctl).exec('launch', { args: ['--terminate-running-process', this.device.udid, this.bundleIdForXctest], env: xctestEnv, }); diff --git a/package.json b/package.json index e81f94a9a..26a0bb660 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "chai-as-promised": "^8.0.0", "conventional-changelog-conventionalcommits": "^9.0.0", "mocha": "^11.0.1", + "node-devicectl": "^1.4.0", "node-simctl": "^8.0.0", "prettier": "^3.0.0", "semantic-release": "^25.0.2", @@ -77,7 +78,6 @@ "@appium/base-driver": "^10.3.0", "@appium/strongbox": "^1.0.0-rc.1", "@appium/support": "^7.0.0-rc.1", - "appium-ios-device": "^3.0.0", "appium-ios-simulator": "^8.0.0", "async-lock": "^1.0.0", "asyncbox": "^6.1.0", diff --git a/test/functional/helpers/simulator.ts b/test/functional/helpers/simulator.ts index 040367f86..166d4910e 100644 --- a/test/functional/helpers/simulator.ts +++ b/test/functional/helpers/simulator.ts @@ -22,7 +22,7 @@ export async function killAllSimulators(): Promise { export async function shutdownSimulator(device: AppleDevice): Promise { // stop XCTest processes if running to avoid unexpected side effects await resetTestProcesses(device.udid, true); - await device.shutdown(); + await (device.simctl as Simctl).shutdownDevice(); } export async function deleteDeviceWithRetry(udid: string): Promise { diff --git a/test/functional/webdriveragent-e2e-specs.ts b/test/functional/webdriveragent-e2e-specs.ts index 5d98329c1..7eee7eac7 100644 --- a/test/functional/webdriveragent-e2e-specs.ts +++ b/test/functional/webdriveragent-e2e-specs.ts @@ -67,7 +67,10 @@ describe('WebDriverAgent', function () { this.timeout(6 * 60 * 1000); beforeEach(async function () { await killAllSimulators(); - await device.run({startupTimeout: SIM_STARTUP_TIMEOUT_MS}); + await (device.simctl as Simctl).startBootMonitor({ + shouldPreboot: true, + timeout: SIM_STARTUP_TIMEOUT_MS, + }); }); afterEach(async function () { try { diff --git a/test/unit/webdriveragent-specs.ts b/test/unit/webdriveragent-specs.ts index cb8cf611f..ec5433ada 100644 --- a/test/unit/webdriveragent-specs.ts +++ b/test/unit/webdriveragent-specs.ts @@ -5,15 +5,17 @@ import {WebDriverAgent} from '../../lib/webdriveragent'; import * as utils from '../../lib/utils'; import path from 'node:path'; import sinon from 'sinon'; -import type {WebDriverAgentArgs, AppleDevice} from '../../lib/types'; +import type {WebDriverAgentArgs} from '../../lib/types'; +import type {Simctl} from 'node-simctl'; +import type {Devicectl} from 'node-devicectl'; chai.use(chaiAsPromised); const fakeConstructorArgs: WebDriverAgentArgs = { device: { udid: 'some-sim-udid', - simctl: {}, - devicectl: {}, + simctl: {} as Simctl, + devicectl: {} as Devicectl, }, platformVersion: '9', host: 'me', @@ -231,48 +233,42 @@ describe('WebDriverAgent', function () { describe('setupCaching()', function () { let wda: WebDriverAgent; let wdaStub: sinon.SinonStub; - let wdaStubUninstall: sinon.SinonStub; const getTimestampStub = sinon.stub(utils, 'getWDAUpgradeTimestamp'); beforeEach(function () { wda = new WebDriverAgent(fakeConstructorArgs); wdaStub = sinon.stub(wda as any, 'getStatus'); - wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); }); afterEach(function () { - for (const stub of [wdaStub, wdaStubUninstall, getTimestampStub]) { + for (const stub of [wdaStub, getTimestampStub]) { if (stub) { stub.reset(); } } }); - it('should not call uninstall since no Running WDA', async function () { + it('should not cache when no WDA is running', async function () { wdaStub.callsFake(function () { return null; }); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall since running WDA has only time', async function () { + it('should cache when running WDA has only time', async function () { wdaStub.callsFake(function () { return {build: {time: 'Jun 24 2018 17:08:21'}}; }); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should call uninstall once since bundle id is not default without updatedWDABundleId capability', async function () { + it('should not cache when bundle id is not default without updatedWDABundleId capability', async function () { wdaStub.callsFake(function () { return { build: { @@ -281,15 +277,13 @@ describe('WebDriverAgent', function () { }, }; }); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should call uninstall once since bundle id is different with updatedWDABundleId capability', async function () { + it('should not cache when bundle id is different with updatedWDABundleId capability', async function () { wdaStub.callsFake(function () { return { build: { @@ -299,21 +293,17 @@ describe('WebDriverAgent', function () { }; }); - wdaStubUninstall.callsFake(() => {}); - - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall since bundle id is equal to updatedWDABundleId capability', async function () { + it('should cache when bundle id is equal to updatedWDABundleId capability', async function () { wda = new WebDriverAgent({ ...fakeConstructorArgs, updatedWDABundleId: 'com.example.WebDriverAgent', }); wdaStub = sinon.stub(wda as any, 'getStatus'); - wdaStubUninstall = sinon.stub(wda as any, 'uninstall'); wdaStub.callsFake(function () { return { @@ -324,115 +314,53 @@ describe('WebDriverAgent', function () { }; }); - wdaStubUninstall.callsFake(() => {}); - - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should call uninstall if current revision differs from the bundled one', async function () { + it('should not cache if current revision differs from the bundled one', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); getTimestampStub.callsFake(async () => 2); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.be.undefined; expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.calledOnce).to.be.true; + expect(wda.webDriverAgentUrl === undefined).to.be.true; }); - it('should not call uninstall if current revision is the same as the bundled one', async function () { + it('should cache if current revision is the same as the bundled one', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); getTimestampStub.callsFake(async () => 1); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should not call uninstall if current revision cannot be retrieved from WDA status', async function () { + it('should cache if current revision cannot be retrieved from WDA status', async function () { wdaStub.callsFake(function () { return {build: {}}; }); getTimestampStub.callsFake(async () => 1); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); - it('should not call uninstall if current revision cannot be retrieved from the file system', async function () { + it('should cache if current revision cannot be retrieved from the file system', async function () { wdaStub.callsFake(function () { return {build: {upgradedAt: '1'}}; }); getTimestampStub.callsFake(async () => null); - wdaStubUninstall.callsFake(() => {}); - await wda.setupCaching(); + expect(await wda.setupCaching()).to.equal('http://127.0.0.1:8100/'); expect(wdaStub.calledOnce).to.be.true; - expect(wdaStubUninstall.notCalled).to.be.true; - }); - - describe('uninstall', function () { - let device: AppleDevice; - let wda: WebDriverAgent; - let deviceGetBundleIdsStub: sinon.SinonStub; - let deviceRemoveAppStub: sinon.SinonStub; - - beforeEach(function () { - device = { - getUserInstalledBundleIdsByBundleName: () => {}, - removeApp: () => {}, - } as any; - wda = new WebDriverAgent({device} as WebDriverAgentArgs); - deviceGetBundleIdsStub = sinon.stub(device, 'getUserInstalledBundleIdsByBundleName'); - deviceRemoveAppStub = sinon.stub(device, 'removeApp'); - }); - - afterEach(function () { - for (const stub of [deviceGetBundleIdsStub, deviceRemoveAppStub]) { - if (stub) { - stub.reset(); - } - } - }); - - it('should not call uninstall', async function () { - deviceGetBundleIdsStub.callsFake(() => []); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.notCalled).to.be.true; - }); - - it('should call uninstall once', async function () { - const uninstalledBundIds: string[] = []; - deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1']); - deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.calledOnce).to.be.true; - expect(uninstalledBundIds).to.eql(['com.appium.WDA1']); - }); - - it('should call uninstall twice', async function () { - const uninstalledBundIds: string[] = []; - deviceGetBundleIdsStub.callsFake(() => ['com.appium.WDA1', 'com.appium.WDA2']); - deviceRemoveAppStub.callsFake((id: string) => uninstalledBundIds.push(id)); - - await (wda as any).uninstall(); - expect(deviceGetBundleIdsStub.calledOnce).to.be.true; - expect(deviceRemoveAppStub.calledTwice).to.be.true; - expect(uninstalledBundIds).to.eql(['com.appium.WDA1', 'com.appium.WDA2']); - }); + expect(wda.webDriverAgentUrl).to.equal('http://127.0.0.1:8100/'); }); });