appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
157 lines (144 loc) • 5.31 kB
text/typescript
import path from 'node:path';
import {plist, fs, util, tempDir, zip} from 'appium/support';
import type {StringRecord} from '@appium/types';
import type {XCUITestDriver} from '../driver';
import {findApps} from './helpers';
import {isEmpty, mergeDeep} from '../utils';
import {APP_EXT} from './constants';
const STRINGSDICT_RESOURCE = '.stringsdict';
const STRINGS_RESOURCE = '.strings';
export interface LocalizableStringsOptions {
app?: string;
language?: string;
localizableStringsDir?: string;
stringFile?: string;
strictMode?: boolean;
}
/**
* Extracts string resources from an app
*/
export async function parseLocalizableStrings(
this: XCUITestDriver,
opts: LocalizableStringsOptions = {},
): Promise<StringRecord> {
const {app, language = 'en', localizableStringsDir, stringFile, strictMode} = opts;
if (!app) {
const message = `Strings extraction is not supported if 'app' capability is not set`;
if (strictMode) {
throw new Error(message);
}
this.log.info(message);
return {};
}
let bundleRoot = app;
const isArchive = (await fs.stat(app)).isFile();
let tmpRoot: string | undefined;
try {
if (isArchive) {
tmpRoot = await tempDir.openDir();
this.log.info(`Extracting '${app}' into a temporary location to parse its resources`);
await zip.extractAllTo(app, tmpRoot);
const relativeBundleRoot = (await findApps(tmpRoot, [APP_EXT]))[0] as string;
this.log.info(`Selecting '${relativeBundleRoot}'`);
bundleRoot = path.join(tmpRoot, relativeBundleRoot);
}
let lprojRoot: string | undefined;
for (const subfolder of [`${language}.lproj`, localizableStringsDir, ''].filter(
(x): x is string => typeof x === 'string',
)) {
lprojRoot = path.resolve(bundleRoot, subfolder as string);
if (await fs.exists(lprojRoot)) {
break;
}
const message = `No '${lprojRoot}' resources folder has been found`;
if (strictMode) {
throw new Error(message);
}
this.log.debug(message);
}
if (!lprojRoot) {
return {};
}
this.log.info(`Retrieving resource strings from '${lprojRoot}'`);
const resourcePaths: string[] = [];
if (stringFile) {
const dstPath = path.resolve(lprojRoot, stringFile);
if (await fs.exists(dstPath)) {
resourcePaths.push(dstPath);
} else {
const message = `No '${dstPath}' resource file has been found for '${app}'`;
if (strictMode) {
throw new Error(message);
}
this.log.info(message);
}
}
if (isEmpty(resourcePaths) && lprojRoot && (await fs.exists(lprojRoot))) {
const resourceFiles = (await fs.readdir(lprojRoot))
.filter((name) => [STRINGS_RESOURCE, STRINGSDICT_RESOURCE].some((x) => name.endsWith(x)))
.map((name) => path.resolve(lprojRoot, name));
resourcePaths.push(...resourceFiles);
}
this.log.info(
`Got ${util.pluralize('resource file', resourcePaths.length, true)} in '${lprojRoot}'`,
);
if (isEmpty(resourcePaths)) {
return {};
}
const resultStrings: StringRecord = {};
const toAbsolutePath = (p: string) => (path.isAbsolute(p) ? p : path.resolve(process.cwd(), p));
for (const resourcePath of resourcePaths) {
if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(bundleRoot))) {
throw new Error(`'${resourcePath}' is expected to be located under '${bundleRoot}'`);
}
try {
const data = await readResource(resourcePath);
this.log.debug(
`Parsed ${util.pluralize('string', Object.keys(data).length, true)} from '${resourcePath}'`,
);
mergeDeep(resultStrings, data);
} catch (e: any) {
this.log.warn(`Cannot parse '${resourcePath}' resource. Original error: ${e.message}`);
}
}
this.log.info(
`Retrieved ${util.pluralize('string', Object.keys(resultStrings).length, true)} from '${lprojRoot}'`,
);
return resultStrings;
} finally {
if (tmpRoot) {
await fs.rimraf(tmpRoot);
}
}
}
/**
* Return the language-specific strings for an app
*
* @param language - The language abbreviation to fetch app strings mapping for.
* If no language is provided then strings for the 'en language would be returned
* @param stringFile - Relative path to the corresponding .strings
* file starting from the corresponding .lproj folder, e.g., `base/main.strings`. If omitted,
* then Appium will make its best guess where the file is.
*
* @returns A record of localized keys to localized text
*/
export async function getStrings(
this: XCUITestDriver,
language: string,
stringFile: string | null = null,
): Promise<StringRecord<string>> {
this.log.debug(`Gettings strings for language '${language}' and string file '${stringFile}'`);
return await parseLocalizableStrings.bind(this)({
...this.opts,
language,
stringFile,
strictMode: true,
});
}
async function readResource(resourcePath: string): Promise<StringRecord> {
const data = await plist.parsePlistFile(resourcePath);
return Object.entries(data).reduce((result, [key, value]) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
return result;
}, {} as StringRecord);
}