diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1be12cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +.AppleDouble +.DS_Store +.env +.env.* +.eslintcache +.grunt +.history +.history.DS_Store +.idea/ +.lock-wscript +.LSOverride +.node_repl_history +.npm +.nyc_output +.vim/ +.vimspector.json +.vscode-test/ +.vscode/ +.vscode/mcp.json +.vscode/settings.json +.yarn-integrity +*.bak +*.log +*.orig +*.pid +*.pid.lock +*.rej +*.seed +*.swo +*.swp +*.test.ts.snap +*.tgz +*.tmp +*.tsbuildinfo +*.vsix +**.bundle +**/Package.resolved +**build +**dist/** +**out/** +$RECYCLE.BIN/ +bower_components +build.config.json +build/Release +config/config.json +config/dbConfig.json +config/keys.json +coverage +coverage/ +Desktop.ini +dist/ +jspm_packages/ +lib-cov +logs +node_modules/ +npm-debug.log* +out/ +pids +src/build.config.json +Thumbs.db +typings/ +yarn-debug.log* +yarn-error.log* +TestProject/ \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..23a1c0b --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "AccessibilityDevTools", + products: [ + .plugin( + name: "a11y-scan", + targets: ["a11y-scan"] + ) + ], + targets: [ + .plugin( + name: "a11y-scan", + capability: .command( + intent: .custom( + verb: "scan", + description: "Scans your iOS project for accessibility issues" + ), + permissions: [ + .allowNetworkConnections( + // scope: .all(ports: []), + scope: .all(), + reason: "Please allow network connection permission to authenticate and run accessibility rules." + ), + ] + ) + ) + ] +) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift new file mode 100644 index 0000000..9e93089 --- /dev/null +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -0,0 +1,618 @@ +import Foundation +import PackagePlugin + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(ucrt) +import ucrt +#endif + +@main +struct BrowserStackAccessibilityLintPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) async throws { + var extractor = ArgumentExtractor(arguments) + let overrideDownloadURLString = extractor.extractOption(named: "download-url").last + let forceDownloadFlag = extractor.extractFlag(named: "force-download") > 0 + let passthrough = extractor.remainingArguments + + let environment = ProcessInfo.processInfo.environment + let forceDownload = forceDownloadFlag || isTruthy(environment["BROWSERSTACK_A11Y_CLI_FORCE_DOWNLOAD"]) + let overrideDownloadURL = try parseOverride(urlString: overrideDownloadURLString ?? environment["BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL"]) + + let cacheRoot = packageCacheRoot() + let artifact = try await BrowserStackCLIArtifact.ensureLatestBinary( + overrideURL: overrideDownloadURL, + forceDownload: forceDownload, + cacheRoot: cacheRoot + ) + + Diagnostics.remark("BrowserStackAccessibilityLint: Using CLI \(artifact.version) at \(artifact.executableURL.path)") + + let sanitizedArguments = sanitizeArguments(passthrough) + let finalArguments = ["a11y"] + sanitizedArguments + + try await runCLI( + executableURL: artifact.executableURL, + arguments: finalArguments, + workingDirectory: context.package.directory + ) + } +} + +private func isTruthy(_ value: String?) -> Bool { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !value.isEmpty else { + return false + } + return ["1", "true", "yes"].contains(value) +} + +private func packageCacheRoot() -> URL { + // NOTE: Ignoring the package directory for caching; using a global user cache folder. + // Order of precedence: + // 1. XDG_CACHE_HOME if set + // 2. HOME/.cache + // 3. NSHomeDirectory()/.cache (fallback) + let env = ProcessInfo.processInfo.environment + let baseCache: URL = { + if let xdg = env["XDG_CACHE_HOME"], !xdg.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: xdg, isDirectory: true) + } + if let home = env["HOME"], !home.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: home, isDirectory: true).appendingPathComponent(".cache", isDirectory: true) + } + return URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true).appendingPathComponent(".cache", isDirectory: true) + }() + + let target = baseCache + .appendingPathComponent("browserstack", isDirectory: true) + .appendingPathComponent("devtools", isDirectory: true) + .appendingPathComponent("spm-plugin", isDirectory: true) + + // Verify write access to cache directory (exit code 2 if not writable) + let fm = FileManager.default + do { + try fm.createDirectory(at: target, withIntermediateDirectories: true) + if !fm.isWritableFile(atPath: target.path) { + forwardExit(code: 2, message: "Unable to access cache directory. Please add \"--allow-writing-to-directory ~/.cache/\" directive in the linter’s build phase command.") + } + let probe = target.appendingPathComponent(".write-probe-\(UUID().uuidString)") + do { + try "probe".data(using: .utf8)?.write(to: probe, options: [.atomic]) + try? fm.removeItem(at: probe) + } catch { + forwardExit(code: 2, message: "Unable to access cache directory. Please include directive \"--allow-writing-to-directory ~/.cache/\" where you are invoking the Swift package") + } + } catch { + forwardExit(code: 2, message: "Unable to access cache directory. Please include directive \"--allow-writing-to-directory ~/.cache/\" where you are invoking the Swift package") + } + + return target +} + +// MARK: - URL / Argument helpers + +private func parseOverride(urlString: String?) throws -> URL? { + guard let urlString = urlString, !urlString.isEmpty else { + return nil + } + if let url = URL(string: urlString), let scheme = url.scheme, ["http", "https", "file"].contains(scheme.lowercased()) { + return url + } + return URL(fileURLWithPath: urlString) +} + +private func sanitizeArguments(_ arguments: [String]) -> [String] { + var result: [String] = [] + var skipNext = false + var passthroughMode = false + + for argument in arguments { + if passthroughMode { + result.append(argument) + continue + } + + if skipNext { + skipNext = false + continue + } + + if argument == "--" { + passthroughMode = true + result.append(argument) + continue + } + + if argument == "--output-format" || argument == "-o" { + skipNext = true + continue + } + + if argument.hasPrefix("--output-format=") { + continue + } + + if argument.count > 2, argument.hasPrefix("-o"), argument != "-o" { + // Handle short-form like "-oxcode". + continue + } + + result.append(argument) + } + + return result +} + +// MARK: - CLI artifact management + +private struct BrowserStackCLIArtifact { + let version: String + let executableURL: URL + + static func ensureLatestBinary(overrideURL: URL?, forceDownload: Bool, cacheRoot: URL) async throws -> BrowserStackCLIArtifact { + let downloader = BrowserStackCLIDownloader(overrideURL: overrideURL, forceDownload: forceDownload, cacheRoot: cacheRoot) + return try await downloader.ensureArtifact() + } +} + +private struct BrowserStackCLIDownloader { + let overrideURL: URL? + let forceDownload: Bool + let cacheRoot: URL + + private var fileManager: FileManager { .default } + + func ensureArtifact() async throws -> BrowserStackCLIArtifact { + if let overrideURL { + let info = try await resolveOverrideArtifact(from: overrideURL) + return try await prepareArtifact(using: info) + } + + let defaultURL = try defaultDownloadURL() + let info = try await resolveRemoteArtifact(from: defaultURL) + return try await prepareArtifact(using: info) + } + + private func ensureCacheRootExists() throws -> URL { + do { + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == CocoaError.fileWriteNoPermission.rawValue { + throw PluginError("Permission denied writing to cache directory at \(cacheRoot.path). Rerun the plugin with --allow-writing-to-package-directory.") + } catch { + throw error + } + return cacheRoot + } + + private func prepareArtifact(using info: ArtifactInfo) async throws -> BrowserStackCLIArtifact { + let cacheRoot = try ensureCacheRootExists() + let versionDirectory = cacheRoot.appendingPathComponent(info.version, isDirectory: true) + let executableName = info.executableName + let expectedExecutableURL = versionDirectory.appendingPathComponent(executableName, isDirectory: false) + + if !forceDownload, fileManager.isExecutableFile(atPath: expectedExecutableURL.path) { + return BrowserStackCLIArtifact(version: info.version, executableURL: expectedExecutableURL) + } + + if fileManager.fileExists(atPath: versionDirectory.path) { + try fileManager.removeItem(at: versionDirectory) + } + try fileManager.createDirectory(at: versionDirectory, withIntermediateDirectories: true) + + Diagnostics.remark("BrowserStackAccessibilityLint: Downloading CLI \(info.version)...") + + #if os(Windows) + let archiveURL = versionDirectory.appendingPathComponent("browserstack-cli.zip") + try await download(from: info.resolvedURL, to: archiveURL) + Diagnostics.remark("BrowserStackAccessibilityLint: Extracting CLI \(info.version)...") + try unzip(archive: archiveURL, into: versionDirectory) + try? fileManager.removeItem(at: archiveURL) + #else + try extractWithBsdtar(from: info.resolvedURL, into: versionDirectory) + #endif + + let locatedBinary = try locateExecutable(in: versionDirectory, preferredName: executableName) + let finalBinaryURL: URL + if locatedBinary.lastPathComponent == executableName { + finalBinaryURL = locatedBinary + } else { + finalBinaryURL = expectedExecutableURL + if fileManager.fileExists(atPath: finalBinaryURL.path) { + try fileManager.removeItem(at: finalBinaryURL) + } + try fileManager.moveItem(at: locatedBinary, to: finalBinaryURL) + } + + try ensureExecutablePermissions(at: finalBinaryURL) + return BrowserStackCLIArtifact(version: info.version, executableURL: finalBinaryURL) + } + +#if !os(Windows) + private func extractWithBsdtar(from url: URL, into directory: URL) throws { + if url.isFileURL { + try extractLocalArchive(at: url, into: directory) + } else { + try extractRemoteArchive(from: url, into: directory) + } + } + + private func extractRemoteArchive(from url: URL, into directory: URL) throws { + let pipe = Pipe() + + let curl = Process() + curl.executableURL = URL(fileURLWithPath: "/usr/bin/env") + curl.arguments = ["curl", "-fsSL", url.absoluteString] + curl.standardOutput = pipe + let curlError = Pipe() + curl.standardError = curlError + + let bsdtar = Process() + bsdtar.executableURL = URL(fileURLWithPath: "/usr/bin/env") + bsdtar.arguments = ["bsdtar", "-xpf", "-", "-C", directory.path] + bsdtar.standardInput = pipe + let tarError = Pipe() + bsdtar.standardError = tarError + + do { + try bsdtar.run() + } catch { + throw PluginError("Unable to launch bsdtar: \(error.localizedDescription)") + } + + do { + try curl.run() + } catch { + bsdtar.terminate() + bsdtar.waitUntilExit() + throw PluginError("Unable to launch curl: \(error.localizedDescription)") + } + + curl.waitUntilExit() + pipe.fileHandleForWriting.closeFile() + bsdtar.waitUntilExit() + + if curl.terminationStatus != 0 { + let message = String(data: curlError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + forwardExit(code: curl.terminationStatus, message: message) + } + + guard bsdtar.terminationReason == .exit, bsdtar.terminationStatus == 0 else { + let message = String(data: tarError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + forwardExit(code: bsdtar.terminationStatus, message: message.isEmpty ? "bsdtar failed to extract BrowserStack CLI." : message) + } + } + + private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["bsdtar", "-xpf", archiveURL.path, "-C", directory.path] + let errorPipe = Pipe() + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + throw PluginError("Failed to launch bsdtar: \(error.localizedDescription)") + } + + if process.terminationReason != .exit || process.terminationStatus != 0 { + // Fall back to copying the file directly if it's already an executable. + let message = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if fileManager.isExecutableFile(atPath: archiveURL.path) { + let destination = directory.appendingPathComponent(archiveURL.lastPathComponent) + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.copyItem(at: archiveURL, to: destination) + } else { + forwardExit(code: process.terminationStatus, message: message.isEmpty ? "bsdtar failed to extract BrowserStack CLI." : message) + } + } + } + #endif + + private func resolveOverrideArtifact(from url: URL) async throws -> ArtifactInfo { + let resolvedURL: URL + if url.isFileURL { + resolvedURL = url + } else { + resolvedURL = try await followRedirects(for: url) + } + let version = extractVersion(from: resolvedURL) ?? "override" + return ArtifactInfo(version: version, resolvedURL: resolvedURL, executableName: executableFileName()) + } + + private func resolveRemoteArtifact(from url: URL) async throws -> ArtifactInfo { + let resolvedURL = try await followRedirects(for: url) + guard let version = extractVersion(from: resolvedURL) else { + throw PluginError("Unable to determine BrowserStack CLI version from \(resolvedURL.absoluteString)") + } + return ArtifactInfo(version: version, resolvedURL: resolvedURL, executableName: executableFileName()) + } + + private func defaultDownloadURL() throws -> URL { + let os = try currentOSName() + let arch = try currentArchName() + guard let url = URL(string: "http://api.browserstack.com/sdk/v1/download_cli?os=\(os)&os_arch=\(arch)") else { + throw PluginError("Failed to create download URL for \(os) \(arch).") + } + return url + } + + private func currentOSName() throws -> String { + #if os(macOS) + return "macos" + #elseif os(Linux) + return isAlpineLinux() ? "alpine" : "linux" + #elseif os(Windows) + return "windows" + #else + throw PluginError("Unsupported operating system for BrowserStack CLI.") + #endif + } + + private func currentArchName() throws -> String { + let machine = try hardwareIdentifier() + switch machine.lowercased() { + case "arm64", "aarch64": + return "arm64" + case "x86_64", "amd64": + return "x64" + default: + throw PluginError("Unsupported architecture '\(machine)' for BrowserStack CLI.") + } + } + + + private func followRedirects(for url: URL) async throws -> URL { + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = 30 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse { + if http.statusCode == 405 || http.statusCode == 501 { + return try await followWithGet(for: url) + } + if let location = http.value(forHTTPHeaderField: "Location"), + let redirectURL = URL(string: location, relativeTo: url)?.absoluteURL { + return redirectURL + } + } + if let finalURL = response.url { + return finalURL + } + } catch let error as URLError where error.code == .badServerResponse || error.code == .unsupportedURL { + return try await followWithGet(for: url) + } catch let error as URLError where error.code == .cannotConnectToHost { + throw PluginError("Network connection failed for \(url.absoluteString): \(error.localizedDescription)") + } catch { + throw error + } + + throw PluginError("Failed to resolve redirect for \(url.absoluteString).") + } + + private func followWithGet(for url: URL) async throws -> URL { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.httpShouldHandleCookies = false + request.timeoutInterval = 60 + + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, + let location = http.value(forHTTPHeaderField: "Location"), + let redirectURL = URL(string: location, relativeTo: url)?.absoluteURL { + return redirectURL + } + guard let finalURL = response.url else { + throw PluginError("Failed to resolve redirect for \(url.absoluteString).") + } + return finalURL + } + + #if os(Windows) + private func download(from url: URL, to destination: URL) async throws { + if url.isFileURL { + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.copyItem(at: url, to: destination) + return + } + + let (tempURL, response) = try await URLSession.shared.download(from: url) + if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) { + throw PluginError("Failed to download BrowserStack CLI (HTTP \(httpResponse.statusCode)).") + } + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: tempURL, to: destination) + } + + private func unzip(archive: URL, into destination: URL) throws { + let powershell = Process() + powershell.executableURL = URL(fileURLWithPath: "powershell") + powershell.arguments = [ + "-NoProfile", + "-Command", + "Expand-Archive -LiteralPath \"\(archive.path)\" -DestinationPath \"\(destination.path)\" -Force" + ] + try run(process: powershell, errorDescription: "Unable to extract BrowserStack CLI archive.") + } +#endif + + private func locateExecutable(in directory: URL, preferredName: String) throws -> URL { + let preferredURL = directory.appendingPathComponent(preferredName, isDirectory: false) + if fileManager.isExecutableFile(atPath: preferredURL.path) { + return preferredURL + } + + let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isExecutableKey], + options: [.skipsHiddenFiles] + ) + + var fallback: URL? + + while let element = enumerator?.nextObject() as? URL { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: element.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + continue + } + + if element.lastPathComponent == preferredName { + return element + } + + if fileManager.isExecutableFile(atPath: element.path) { + return element + } + + if fallback == nil { + fallback = element + } + } + + if let fallback { + return fallback + } + + throw PluginError("Extracted archive does not contain a binary payload.") + } + + private func ensureExecutablePermissions(at url: URL) throws { + #if os(Windows) + _ = url + #else + var attributes = [FileAttributeKey: Any]() + attributes[.posixPermissions] = 0o755 + try fileManager.setAttributes(attributes, ofItemAtPath: url.path) + #endif + } + + private func run(process: Process, errorDescription: String) throws { + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + process.standardInput = FileHandle.standardInput + try process.run() + process.waitUntilExit() + guard process.terminationReason == .exit else { + forwardExit(code: 1, message: errorDescription) + } + let status = process.terminationStatus + guard status == 0 else { + forwardExit(code: status, message: errorDescription) + } + } + + private func executableFileName() -> String { + #if os(Windows) + return "browserstack-cli.exe" + #else + return "browserstack-cli" + #endif + } +} + +private struct ArtifactInfo { + let version: String + let resolvedURL: URL + let executableName: String +} + +// MARK: - System helpers + +private func hardwareIdentifier() throws -> String { + #if os(Windows) + if let arch = ProcessInfo.processInfo.environment["PROCESSOR_ARCHITECTURE"]?.lowercased() { + return arch + } + throw PluginError("Unable to detect CPU architecture.") + #else + var systemInfo = utsname() + guard uname(&systemInfo) == 0 else { + throw PluginError("uname() failed to determine CPU architecture.") + } + + let capacity = MemoryLayout.size(ofValue: systemInfo.machine) + let identifier = withUnsafePointer(to: &systemInfo.machine) { ptr -> String in + return ptr.withMemoryRebound(to: CChar.self, capacity: capacity) { + String(cString: $0) + } + }.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) + return identifier + #endif +} + +private func extractVersion(from url: URL) -> String? { + let filename = url.deletingPathExtension().lastPathComponent + if let range = filename.range(of: "-", options: .backwards) { + let version = filename[range.upperBound...] + return version.isEmpty ? nil : String(version) + } + return nil +} + +#if os(Linux) +private func isAlpineLinux() -> Bool { + guard let contents = try? String(contentsOfFile: "/etc/os-release") else { + return false + } + return contents.contains("ID=alpine") +} +#else +private func isAlpineLinux() -> Bool { false } +#endif + +// MARK: - CLI invocation + + private func runCLI(executableURL: URL, arguments: [String], workingDirectory: PackagePlugin.Path) async throws { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.string, isDirectory: true) + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + + guard process.terminationReason == .exit else { + forwardExit(code: 1, message: "browserstack-cli terminated abnormally.") + } + + let status = process.terminationStatus + guard status == 0 else { + forwardExit(code: status, message: "") + } + } + +// MARK: - Error + +private struct PluginError: Error, CustomStringConvertible { + let message: String + + init(_ message: String) { + self.message = message + } + + var description: String { message } +} + +private func forwardExit(code: Int32, message: String) -> Never { + if !message.isEmpty, let data = (message + "\n").data(using: .utf8) { + FileHandle.standardError.write(data) + } + exit(code) +} diff --git a/README.md b/README.md index 9d05012..bcff8ed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ # AccessibilityDevTools A Swift Package Manager (SPM) command plugin and CLI tool that scans your iOS Swift codebase for accessibility issues using BrowserStack’s Accessibility DevTools rule engine. + +AccessibilityDevTools enables static accessibility linting directly inside Xcode, via SwiftPM, or using the standalone BrowserStack CLI, helping teams catch WCAG violations early—before UI tests, QA, or production. + +--- +## 🚀 Key Capabilities +* 🔍 **Automatic static accessibility linting** for SwiftUI +* 🛠 **10+ WCAG-aligned rules** from the Spectra rule engine +* 🛠 **Inline errors inside Xcode** with remediation guidance +* ⚡ **Runs during build** using the SPM command plugin + +--- +## Authentication +1. Log in to your BrowserStack account or [sign up](https://www.browserstack.com/users/sign_in) if you don’t have an account. +2. Obtain your **Username** and **Access Key** from the [Account & Profile section](https://www.browserstack.com/accounts/profile/details) section on the dashboard. +![Account & Profile section](./resources/a31150e8-6beb-4541-bc2a-e1d9f03e431d.png "Account & Profile section") + +3. Set the following environment variables using the **Username** and **Access Key** you obtained in step 2. + * `BROWSERSTACK_USERNAME` + * `BROWSERSTACK_ACCESS_KEY` +4. To set these variables, add the appropriate export commands to your shell configuration file: + * **Zsh**: Add the following lines to your `~/.zshrc` file: + ```zsh + export BROWSERSTACK_USERNAME="" + export BROWSERSTACK_ACCESS_KEY="" + ``` + * **Bash**: Add the following lines to your `~/.bashrc` or `~/.bash_profile` file: + ```bash + export BROWSERSTACK_USERNAME="" + export BROWSERSTACK_ACCESS_KEY="" + ``` + * **Fish Shell**: Add the following lines to your ~/.config/fish/config.fish file: + ```fish + set -x BROWSERSTACK_USERNAME + set -x BROWSERSTACK_ACCESS_KEY + ``` + +--- +## Installation +### SwiftPM Projects +For SwiftPM projects, you can use the SPM `command plugin` for Accessibility DevTools. + +**Add plugin in your `Package.swift`** + +Edit the `Project.swift` to include following code. Specifically, these two things to be added + +* Add `AccessibilityDevTools` as a package under dependencies + +* Add `a11y-scan` as a plugin under each target that you have in your project + +```swift +let package = Package( + name: "MySPMProject", + dependencies: [ + .package(url: "https://github.com/browserstack/AccessibilityDevTools.git", from: "1.0.0") + ], + targets: [ + .executableTarget( + name: "MyApp", + dependencies: [], + plugins: [ + .plugin(name: "a11y-scan", package: "AccessibilityDevTools") + ] + ) + ] +) +``` +**Add a Build Phase to run the plugin** +1. Select first item (project root) in the left folder tree and go to Build Phases tab +![Build Phases](./resources/25519c9c-87a9-41af-b97b-23f875faf3b7.png "Build Phases") +2. Click + to create a new build phase. Name the newly created build phase to a name such as **BrowserStack Accessibility Linter** +3. Drag this newly created build phase above **Compile Sources** step +4. Delete any existing code in the newly created build step and add the following code. +5. Add this script: +```bash +/usr/bin/xcrun swift package scan --disable-sandbox --include **/*.swift +``` + +Xcode will now automatically run the accessibility scan during builds. + +### Non-SwiftPM Projects +For all non-SwiftPM projects (e.g. Xcode projects), you can use the **browserstack-cli** + +**Install CLI in the project repo** +1. Open terminal and navigate to the project folder. +2. Run the commands provided in the [documentation](https://www.browserstack.com/docs/accessibility-dev-tools/run-checks-cli#install-the-cli) + +**Disable Sandboxing** +1. In Xcode project, select first item (project root) in the left folder tree and go to Build Settings tab +2. Search for sandbox > Set user script sandboxing to “NO” + +**Add a Build Phase to run the plugin** +1. Select first item (project root) in the left folder tree and go to Build Phases tab +![Build Phases](./resources/25519c9c-87a9-41af-b97b-23f875faf3b7.png "Build Phases") +2. Click + to create a new build phase. Name the newly created build phase to a name such as **BrowserStack Accessibility Linter** +3. Drag this newly created build phase above **Compile Sources** step +4. Delete any existing code in the newly created build step and add the following code. +5. Add this script: +```bash +./browserstack-cli accessibility --include **/*.swift +``` + +--- +## Running Accessibility Scans +Press Cmd + B to build the project. If there are no errors from the linter (and any other build steps you have), the build will succeed. + +If issues are found: + +* Inline red markers show errors in files. Click on the cross mark to see the full error. +![Diagnostics](./resources/bb7fbc3b-6d19-47e9-93c5-8c4b0ae124a6.png "Diagnostics") +* All issues appear in the **Issue Navigator** +![Issue Navigator](./resources/ff9e25c4-0d57-4423-ae0f-fa77b56d99a7.png "Issue Navigator") + +--- +## Support +For any issues or feedback, reach out to support@browserstack.com \ No newline at end of file diff --git a/resources/25519c9c-87a9-41af-b97b-23f875faf3b7.png b/resources/25519c9c-87a9-41af-b97b-23f875faf3b7.png new file mode 100644 index 0000000..6c767a2 Binary files /dev/null and b/resources/25519c9c-87a9-41af-b97b-23f875faf3b7.png differ diff --git a/resources/a31150e8-6beb-4541-bc2a-e1d9f03e431d.png b/resources/a31150e8-6beb-4541-bc2a-e1d9f03e431d.png new file mode 100644 index 0000000..84aebbe Binary files /dev/null and b/resources/a31150e8-6beb-4541-bc2a-e1d9f03e431d.png differ diff --git a/resources/bb7fbc3b-6d19-47e9-93c5-8c4b0ae124a6.png b/resources/bb7fbc3b-6d19-47e9-93c5-8c4b0ae124a6.png new file mode 100644 index 0000000..afacc6f Binary files /dev/null and b/resources/bb7fbc3b-6d19-47e9-93c5-8c4b0ae124a6.png differ diff --git a/resources/ff9e25c4-0d57-4423-ae0f-fa77b56d99a7.png b/resources/ff9e25c4-0d57-4423-ae0f-fa77b56d99a7.png new file mode 100644 index 0000000..184b8ce Binary files /dev/null and b/resources/ff9e25c4-0d57-4423-ae0f-fa77b56d99a7.png differ