mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 11:51:35 +00:00
* feat(gateway): accept WebSocket request trace context * test(gateway): prove WebSocket trace isolation * test(gateway): simplify traced response match * ci: allow iOS screenshot validation to finish * fix(gateway): keep traced request failures scoped * test(ci): align iOS screenshot timeout contract * test(ui): reset config route location * ci: scope iOS screenshots to native changes --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
417 lines
20 KiB
TypeScript
417 lines
20 KiB
TypeScript
// iOS Fastlane release gate tests keep TestFlight upload on one canonical path.
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const fastfilePath = path.join(process.cwd(), "apps", "ios", "fastlane", "Fastfile");
|
|
const packageJsonPath = path.join(process.cwd(), "package.json");
|
|
const legacyReleaseScriptPath = path.join(process.cwd(), "scripts", "ios-release.sh");
|
|
const uploadScriptPath = path.join(process.cwd(), "scripts", "ios-release-upload.sh");
|
|
const snapshotUITestPath = path.join(
|
|
process.cwd(),
|
|
"apps",
|
|
"ios",
|
|
"UITests",
|
|
"OpenClawSnapshotUITests.swift",
|
|
);
|
|
const rootTabsPath = path.join(process.cwd(), "apps", "ios", "Sources", "RootTabs.swift");
|
|
const ciWorkflowPath = path.join(process.cwd(), ".github", "workflows", "ci.yml");
|
|
|
|
function readFastfile(): string {
|
|
return readFileSync(fastfilePath, "utf8");
|
|
}
|
|
|
|
function laneBody(source: string, name: string): string {
|
|
const startMarker = `lane :${name} do`;
|
|
const start = source.indexOf(startMarker);
|
|
if (start < 0) {
|
|
throw new Error(`missing Fastlane lane ${name}`);
|
|
}
|
|
|
|
const rest = source.slice(start + startMarker.length);
|
|
const nextLane = rest.search(/\n\s+(?:desc|lane|private_lane) /);
|
|
return nextLane < 0 ? rest : rest.slice(0, nextLane);
|
|
}
|
|
|
|
function functionBody(source: string, name: string): string {
|
|
const startMarker = `def ${name}`;
|
|
const start = source.indexOf(startMarker);
|
|
if (start < 0) {
|
|
throw new Error(`missing Fastfile function ${name}`);
|
|
}
|
|
|
|
const rest = source.slice(start + startMarker.length);
|
|
const nextFunction = rest.search(/\ndef /);
|
|
return nextFunction < 0 ? rest : rest.slice(0, nextFunction);
|
|
}
|
|
|
|
function swiftFunctionBody(source: string, name: string): string {
|
|
const startMarker = `func ${name}(`;
|
|
const start = source.indexOf(startMarker);
|
|
if (start < 0) {
|
|
throw new Error(`missing Swift function ${name}`);
|
|
}
|
|
|
|
const rest = source.slice(start + startMarker.length);
|
|
const nextFunction = rest.search(/\n (?:private )?func /);
|
|
return nextFunction < 0 ? rest : rest.slice(0, nextFunction);
|
|
}
|
|
|
|
describe("iOS Fastlane release upload gates", () => {
|
|
it("does not keep the old package release alias", () => {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
|
|
expect(packageJson.scripts).toHaveProperty("ios:release:upload");
|
|
expect(packageJson.scripts).toHaveProperty("ios:release:plan");
|
|
expect(packageJson.scripts).toHaveProperty("ios:release:cut");
|
|
expect(packageJson.scripts).not.toHaveProperty("ios:release");
|
|
expect(existsSync(legacyReleaseScriptPath)).toBe(false);
|
|
});
|
|
|
|
it("routes the package upload wrapper through the guarded Fastlane lane", () => {
|
|
const script = readFileSync(uploadScriptPath, "utf8");
|
|
|
|
expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1");
|
|
expect(script).not.toContain("Missing required --version.");
|
|
expect(script).not.toContain("Missing required --revision.");
|
|
expect(script).toContain('"release_version:${RELEASE_VERSION}"');
|
|
expect(script).toContain('"app_store_revision:${APP_STORE_REVISION}"');
|
|
expect(script).toContain('"build_number:${BUILD_NUMBER}"');
|
|
expect(script).toContain("DELIVER_NUMBER_OF_THREADS=1");
|
|
expect(script).toContain("FL_MAX_NUMBER_OF_THREADS=1");
|
|
expect(script).toContain('run_ios_fastlane "${FASTLANE_ARGS[@]}"');
|
|
});
|
|
|
|
it("keeps release_upload as the only Fastlane TestFlight upload implementation", () => {
|
|
const fastfile = readFastfile();
|
|
const uploadCalls = fastfile.match(/\bupload_to_testflight\s*\(/g) ?? [];
|
|
|
|
expect(uploadCalls).toHaveLength(1);
|
|
expect(laneBody(fastfile, "release_upload")).toContain("upload_to_testflight(");
|
|
expect(fastfile).not.toMatch(/\n\s+lane :app_store do\b/);
|
|
expect(fastfile).not.toContain("Deprecated. Use `pnpm ios:release:upload`.");
|
|
});
|
|
|
|
it("rejects direct Fastlane upload before release work", () => {
|
|
const fastfile = readFastfile();
|
|
const releaseUpload = laneBody(fastfile, "release_upload");
|
|
const prepareContext = laneBody(fastfile, "prepare_app_store_context");
|
|
|
|
expect(releaseUpload).toContain('ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"');
|
|
expect(releaseUpload).toContain("Use `pnpm ios:release:upload`");
|
|
expect(prepareContext).toContain("options[:release_version]");
|
|
expect(prepareContext).toContain("options[:app_store_revision]");
|
|
expect(prepareContext).toContain("options[:build_number]");
|
|
expect(prepareContext).toContain("resolve_ios_release_plan!");
|
|
expect(prepareContext).toContain('release_plan.fetch("gatewayVersion")');
|
|
expect(prepareContext).toContain('release_plan.fetch("appStoreRevision")');
|
|
expect(prepareContext).toContain('release_plan.fetch("buildNumber")');
|
|
expect(releaseUpload).toContain("app_store_revision: context[:app_store_revision]");
|
|
expect(laneBody(fastfile, "metadata")).toContain("options[:release_version]");
|
|
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS gateway version");
|
|
expect(laneBody(fastfile, "metadata")).toContain("Missing iOS App Store revision");
|
|
expect(releaseUpload.indexOf("UI.user_error!")).toBeLessThan(
|
|
releaseUpload.indexOf("prepare_app_store_context"),
|
|
);
|
|
});
|
|
|
|
it("preflights the exact App Store version before screenshots and archive work", () => {
|
|
const fastfile = readFastfile();
|
|
const releaseUpload = laneBody(fastfile, "release_upload");
|
|
const preflight = functionBody(fastfile, "preflight_app_store_version!");
|
|
|
|
expect(preflight).toContain("EDITABLE_APP_STORE_VERSION_STATES");
|
|
expect(preflight).toContain("RELEASED_APP_STORE_VERSION_STATES");
|
|
expect(fastfile).toContain('"READY_FOR_SALE"');
|
|
expect(fastfile).toContain('"REMOVED_FROM_SALE"');
|
|
expect(fastfile).toContain('"DEVELOPER_REMOVED_FROM_SALE"');
|
|
expect(fastfile).not.toMatch(
|
|
/EDITABLE_APP_STORE_VERSION_STATES = \[[\s\S]*?"WAITING_FOR_REVIEW"[\s\S]*?\]\.freeze/,
|
|
);
|
|
expect(preflight).toContain("Revisions are never reused");
|
|
expect(preflight).toContain("higher version");
|
|
expect(releaseUpload).toContain("preflight_app_store_version!");
|
|
expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan(
|
|
releaseUpload.indexOf("screenshots("),
|
|
);
|
|
expect(releaseUpload.indexOf("preflight_app_store_version!")).toBeLessThan(
|
|
releaseUpload.indexOf("build = build_app_store_release(context)"),
|
|
);
|
|
});
|
|
|
|
it("validates explicit build numbers against the exact App Store version", () => {
|
|
const resolver = functionBody(readFastfile(), "resolve_release_build_number");
|
|
|
|
expect(resolver).toContain("app_store_build_uploads");
|
|
expect(resolver).toContain("IOS_BUILD_UPLOAD_STATES");
|
|
expect(resolver).toContain("expected #{next_build}");
|
|
expect(resolver).toContain("explicit.to_i != next_build");
|
|
expect(resolver).toContain("api_key.nil?");
|
|
expect(resolver).not.toContain("latest_testflight_build_number");
|
|
});
|
|
|
|
it("plans revisions and builds from App Store versions and build uploads", () => {
|
|
const fastfile = readFastfile();
|
|
const planner = functionBody(fastfile, "resolve_ios_release_plan!");
|
|
const planLane = laneBody(fastfile, "release_plan");
|
|
const uploadState = functionBody(fastfile, "app_store_build_upload_state");
|
|
|
|
expect(planner).toContain("get_app_store_versions");
|
|
expect(planner).toContain("app_store_build_uploads");
|
|
expect(planner).toContain("app_store_build_upload_state(upload)");
|
|
expect(uploadState).toContain('detail["state"]');
|
|
expect(uploadState).toContain("expected a StateDetail object");
|
|
expect(planner).toContain("does not match canonical root version");
|
|
expect(planner).toContain('File.join(repo_root, "scripts", "ios-release-plan.ts")');
|
|
expect(planLane).toContain("resolve_ios_release_plan!");
|
|
expect(planLane).toContain("JSON.pretty_generate(plan)");
|
|
});
|
|
|
|
it("validates the exported IPA before the sole TestFlight upload call", () => {
|
|
const fastfile = readFastfile();
|
|
const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]");
|
|
const uploadCall = fastfile.indexOf("upload_to_testflight(");
|
|
|
|
expect(validationCall).toBeGreaterThanOrEqual(0);
|
|
expect(uploadCall).toBeGreaterThan(validationCall);
|
|
});
|
|
|
|
it("rechecks the plan after local validation and before the first App Store mutation", () => {
|
|
const fastfile = readFastfile();
|
|
const releaseUpload = laneBody(fastfile, "release_upload");
|
|
const build = releaseUpload.indexOf("build = build_app_store_release(context)");
|
|
const planRecheck = releaseUpload.lastIndexOf("resolve_ios_release_plan!");
|
|
const metadata = releaseUpload.indexOf("\n metadata(");
|
|
const upload = releaseUpload.indexOf("upload_to_testflight(");
|
|
|
|
expect(fastfile).not.toContain("def verify_app_store_binary!");
|
|
expect(releaseUpload).not.toContain("verify_only: true");
|
|
expect(build).toBeGreaterThanOrEqual(0);
|
|
expect(planRecheck).toBeGreaterThan(build);
|
|
expect(metadata).toBeGreaterThan(planRecheck);
|
|
expect(upload).toBeGreaterThan(planRecheck);
|
|
});
|
|
|
|
it("waits for Apple build processing without submitting to TestFlight review", () => {
|
|
const releaseUpload = laneBody(readFastfile(), "release_upload");
|
|
|
|
expect(releaseUpload).toContain("skip_waiting_for_build_processing: false");
|
|
expect(releaseUpload).toContain("skip_submission: true");
|
|
expect(releaseUpload).toContain(
|
|
"wait_processing_timeout_duration: APP_STORE_BUILD_PROCESSING_TIMEOUT_SECONDS",
|
|
);
|
|
expect(releaseUpload).not.toContain("skip_waiting_for_build_processing: true");
|
|
});
|
|
|
|
it("finishes fallible local release work before mutating App Store metadata", () => {
|
|
const fastfile = readFastfile();
|
|
const releaseUpload = laneBody(fastfile, "release_upload");
|
|
const screenshots = releaseUpload.indexOf(
|
|
"screenshots(\n release_version: context[:version]",
|
|
);
|
|
const sourceCheck = releaseUpload.indexOf("verify_apple_release_source!(release_sha)");
|
|
const build = releaseUpload.indexOf("build = build_app_store_release(context)");
|
|
const metadata = releaseUpload.indexOf("metadata(\n release_version: context[:version]");
|
|
|
|
expect(screenshots).toBeGreaterThanOrEqual(0);
|
|
expect(sourceCheck).toBeGreaterThan(screenshots);
|
|
expect(build).toBeGreaterThan(sourceCheck);
|
|
expect(metadata).toBeGreaterThan(build);
|
|
});
|
|
|
|
it("fails from authoritative Xcode results and keeps successful bundles outside screenshots", () => {
|
|
const fastfile = readFastfile();
|
|
const screenshots = laneBody(fastfile, "screenshots");
|
|
const capture = functionBody(fastfile, "capture_release_ios_screenshot!");
|
|
const archive = functionBody(fastfile, "archive_snapshot_test_result!");
|
|
const verifier = functionBody(fastfile, "verify_snapshot_test_result!");
|
|
|
|
expect(screenshots).toContain("devices = snapshot_devices");
|
|
expect(screenshots).toContain("build_for_testing: true");
|
|
expect(screenshots).toContain("RELEASE_IOS_SCREENSHOT_TESTS.each");
|
|
expect(screenshots).toContain("capture_release_ios_screenshot!(");
|
|
expect(capture).toContain("1.upto(2)");
|
|
expect(screenshots).toContain(
|
|
"result_bundle_archive_directory: result_bundle_archive_directory",
|
|
);
|
|
expect(capture).toContain(
|
|
'only_testing: ["OpenClawUITests/OpenClawSnapshotUITests/#{test_name}"]',
|
|
);
|
|
expect(capture).toContain("test_without_building: true");
|
|
expect(capture).toContain("result_bundle: true");
|
|
expect(capture).toContain("number_of_retries: 0");
|
|
expect(capture).toContain("stop_after_first_error: true");
|
|
expect(capture).toContain("retrying once in a fresh simulator session");
|
|
expect(capture).toContain("verify_snapshot_test_result!");
|
|
expect(archive).toContain('"#{device}-#{screenshot_name}-attempt-#{attempt}.xcresult"');
|
|
expect(screenshots).toContain("verify_release_ios_screenshot_manifest!(");
|
|
expect(screenshots).toContain(
|
|
'result_bundle_archive_directory = File.join(ios_root, "build", "SnapshotTestResults")',
|
|
);
|
|
expect(screenshots.indexOf("capture_release_ios_screenshot!")).toBeLessThan(
|
|
screenshots.indexOf('FileUtils.rm_rf(File.join(output_directory, "test_output"))'),
|
|
);
|
|
expect(verifier).toContain('"xcresulttool"');
|
|
expect(verifier).toContain('summary.fetch("failedTests")');
|
|
expect(verifier).toContain("UI.test_failure!");
|
|
});
|
|
|
|
it("captures each release screen from an independent direct launch", () => {
|
|
const snapshotUITest = readFileSync(snapshotUITestPath, "utf8");
|
|
const releaseTests = [
|
|
["testReleaseControlScreenshot", "controlScreenshotTarget"],
|
|
["testReleaseChatScreenshot", "chatScreenshotTarget"],
|
|
["testReleaseAgentScreenshot", "agentScreenshotTarget"],
|
|
["testReleaseSettingsScreenshot", "settingsScreenshotTarget"],
|
|
] as const;
|
|
const captureHelper = swiftFunctionBody(snapshotUITest, "captureReleaseScreenshot");
|
|
const launchHelper = swiftFunctionBody(snapshotUITest, "launchApp");
|
|
const navigationTest = swiftFunctionBody(
|
|
snapshotUITest,
|
|
"testAgentsNavigateToSettingsThroughSidebar",
|
|
);
|
|
const rootTabs = readFileSync(rootTabsPath, "utf8");
|
|
|
|
for (const [testName, targetName] of releaseTests) {
|
|
const releaseTest = swiftFunctionBody(snapshotUITest, testName);
|
|
expect(releaseTest).toContain(`self.captureReleaseScreenshot(Self.${targetName})`);
|
|
}
|
|
expect(captureHelper.match(/self\.launchApp\(/g)).toHaveLength(1);
|
|
expect(captureHelper).toContain("waitForReleaseScreenshotTarget");
|
|
expect(launchHelper).toContain("app.launch()");
|
|
expect(snapshotUITest).not.toContain("screenshotLaunchRetryThreshold");
|
|
expect(snapshotUITest).not.toContain("selectReleaseScreenshotDestination");
|
|
expect(navigationTest).toContain("self.launchApp(for: Self.agentScreenshotTarget)");
|
|
expect(navigationTest).toContain('self.selectSidebarDestination("Settings")');
|
|
expect(navigationTest).toContain('"settings-system-agent-row"');
|
|
expect(navigationTest).not.toContain("XCTExpectFailure");
|
|
expect(navigationTest).not.toContain("XCTExpectedFailure");
|
|
expect(rootTabs).toContain("self.scenePhase == .active");
|
|
expect(rootTabs).toContain("self.selectedSidebarDestination.rawValue");
|
|
});
|
|
|
|
it("requires the exact nonempty PNG manifest before Watch capture", () => {
|
|
const fastfile = readFastfile();
|
|
const screenshots = laneBody(fastfile, "screenshots");
|
|
const verifier = functionBody(fastfile, "verify_release_ios_screenshot_manifest!");
|
|
|
|
expect(fastfile).toContain("REQUIRED_IOS_SCREENSHOT_NAMES");
|
|
expect(verifier).toContain("expected_names - actual_names");
|
|
expect(verifier).toContain("actual_names - expected_names");
|
|
expect(verifier).toContain("File.size?(path)");
|
|
expect(verifier).toContain("PNG_SIGNATURE");
|
|
expect(screenshots.indexOf("verify_release_ios_screenshot_manifest!")).toBeGreaterThan(
|
|
screenshots.indexOf("RELEASE_IOS_SCREENSHOT_TESTS.each"),
|
|
);
|
|
expect(screenshots.indexOf("verify_release_ios_screenshot_manifest!")).toBeLessThan(
|
|
screenshots.indexOf("watch_screenshot("),
|
|
);
|
|
});
|
|
|
|
it("runs the exact screenshot lane during native Apple, manual, and full release CI", () => {
|
|
const workflow = readFileSync(ciWorkflowPath, "utf8");
|
|
const iosJobStart = workflow.indexOf("\n ios-build:\n");
|
|
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
|
|
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
|
|
|
|
expect(iosJob).toContain("timeout-minutes: 150");
|
|
expect(iosJob).toContain("Capture iOS release screenshots");
|
|
expect(iosJob).toContain("github.event_name == 'workflow_dispatch'");
|
|
expect(iosJob).toContain("github.event_name == 'pull_request'");
|
|
expect(iosJob).toContain("needs.preflight.outputs.run_macos == 'true'");
|
|
expect(iosJob).toContain("run: pnpm ios:screenshots");
|
|
expect(iosJob).toContain("Upload iOS release screenshot evidence");
|
|
expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");
|
|
expect(iosJob).toContain("if-no-files-found: error");
|
|
});
|
|
|
|
it("preserves caller-pinned Swift tools in archive build PATH", () => {
|
|
const fastfile = readFastfile();
|
|
const pathBuilder = functionBody(fastfile, "xcodebuild_shell_join");
|
|
const callerPath = 'ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)';
|
|
|
|
expect(pathBuilder).toContain(callerPath);
|
|
expect(pathBuilder).toContain(".reject(&:empty?).uniq.join(File::PATH_SEPARATOR)");
|
|
expect(pathBuilder).toContain(
|
|
"system_tools_first ? [*system_path, *caller_path] : [*caller_path, *system_path]",
|
|
);
|
|
});
|
|
|
|
it("uses Apple's matched rsync pair when exporting the IPA", () => {
|
|
const fastfile = readFastfile();
|
|
const builder = functionBody(fastfile, "build_app_store_release");
|
|
const exportStart = builder.indexOf('"-exportArchive"');
|
|
|
|
expect(exportStart).toBeGreaterThanOrEqual(0);
|
|
expect(builder.slice(exportStart)).toContain("system_tools_first: true");
|
|
});
|
|
|
|
it("requires clean matching source before preparing and building release artifacts", () => {
|
|
const fastfile = readFastfile();
|
|
const verifier = functionBody(fastfile, "verify_apple_release_source!");
|
|
const provenance = functionBody(fastfile, "pin_release_build_provenance!");
|
|
const builder = functionBody(fastfile, "build_app_store_release");
|
|
|
|
expect(verifier).toContain('"apple-release-source-check.sh"');
|
|
expect(verifier).toContain('"--root"');
|
|
expect(verifier).toContain('"--expected-commit"');
|
|
expect(provenance).toContain("verify_apple_release_source!(normalized_commit)");
|
|
expect(provenance).not.toContain('ENV["GITHUB_SHA"]');
|
|
expect(builder).toContain("verify_apple_release_source!(context[:git_commit])");
|
|
expect(builder.indexOf("verify_apple_release_source!")).toBeLessThan(
|
|
builder.indexOf("FileUtils.mkdir_p(output_directory)"),
|
|
);
|
|
});
|
|
|
|
it("preflights and records mobile release refs around TestFlight upload", () => {
|
|
const fastfile = readFastfile();
|
|
const releaseUpload = laneBody(fastfile, "release_upload");
|
|
|
|
expect(fastfile).toContain("def mobile_release_ref_command");
|
|
expect(fastfile).toContain("def release_git_sha");
|
|
expect(fastfile).toContain('"--root"');
|
|
expect(fastfile).toContain('"--sha"');
|
|
expect(fastfile).toContain("repo_root");
|
|
expect(fastfile).toContain("def pin_release_build_provenance!");
|
|
expect(laneBody(fastfile, "prepare_app_store_context")).toContain(
|
|
"provenance = pin_release_build_provenance!",
|
|
);
|
|
expect(releaseUpload).toContain("release_sha = context[:git_commit]");
|
|
expect(releaseUpload).toContain("ensure_mobile_release_ref_available!");
|
|
expect(releaseUpload).toContain("record_mobile_release_ref!");
|
|
expect(releaseUpload).toContain("screenshots(\n release_version: context[:version]");
|
|
expect(fastfile).toContain("def without_xcode_xcconfig_file");
|
|
expect(releaseUpload).toContain("without_xcode_xcconfig_file do");
|
|
expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2);
|
|
expect(releaseUpload.indexOf("prepare_app_store_context")).toBeLessThan(
|
|
releaseUpload.indexOf("screenshots(\n release_version: context[:version]"),
|
|
);
|
|
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
|
releaseUpload.indexOf("screenshots(\n release_version: context[:version]"),
|
|
);
|
|
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
|
releaseUpload.indexOf("\n metadata(\n release_version: context[:version]"),
|
|
);
|
|
expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
|
|
releaseUpload.indexOf("upload_to_testflight("),
|
|
);
|
|
});
|
|
|
|
it("normalizes Watch screenshots as opaque RGB PNGs for App Store upload", () => {
|
|
const fastfile = readFastfile();
|
|
|
|
expect(laneBody(fastfile, "screenshots")).toContain(
|
|
'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args',
|
|
);
|
|
expect(laneBody(fastfile, "watch_screenshot")).toContain(
|
|
'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args',
|
|
);
|
|
expect(fastfile).toContain("def normalize_watch_screenshot_status_bar(path)");
|
|
expect(fastfile).toContain("CGImageAlphaInfo.noneSkipLast.rawValue");
|
|
expect(fastfile).toContain("CGImageDestinationCreateWithURL");
|
|
expect(fastfile).toContain("operation: .sourceOver");
|
|
});
|
|
});
|