mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
* Run extensions that speak WebSocket A bundled `ws` handshakes through `http.request` and wants a raw socket back, which the fetch-backed shim had none of. Sockets are `URLSessionWebSocketTask` now, and the upgrade path hands `ws` one that re-frames RFC 6455 both ways. Home Assistant is the reference case. * Resolve a .local host without joining a multicast group Home Assistant's default `homeassistant.local` is resolved by the extension itself with multicast-dns, which died on `dgram.createSocket`. `dgram` now answers an address query out of `getaddrinfo` — mDNSResponder handles `.local` — so nothing multicasts and no entitlement is needed. * Harden the WebSocket and dgram shims A rejected host call poisoned the send queue, so every later send and the close after it rejected too; the queue now recovers while the caller still sees the failure. A ping is answered by the peer through `URLSessionWebSocketTask`, not by a pong the adapter invents. An upgrade no listener claims destroys the socket, the way Node does, so the native task goes with it. `dgram` answers address questions only. And a socket id that is not a whole number falls back instead of trapping.
1694 lines
83 KiB
Swift
1694 lines
83 KiB
Swift
// Compiles the real engine sources against JavaScriptCore; pass a directory to run one.
|
||
|
||
import AppKit
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
@main
|
||
struct ExtensionTests {
|
||
static func main() async {
|
||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||
if let directory = arguments.first {
|
||
await runInstalledExtension(
|
||
directory: URL(fileURLWithPath: directory), command: arguments.dropFirst().first)
|
||
} else {
|
||
await runChecks()
|
||
}
|
||
}
|
||
|
||
// MARK: - Harness plumbing
|
||
|
||
/// `proc` and `fetch` are the app's real ones; main-actor calls get canned answers.
|
||
@MainActor
|
||
final class StubHost: ExtensionHostAPI {
|
||
var calls: [String] = []
|
||
var toasts: [String] = []
|
||
var huds: [String] = []
|
||
var oauthTokens: [String: String] = [:]
|
||
private let fetcher = ExtensionFetcher()
|
||
private let sockets = ExtensionWebSocketBridge()
|
||
|
||
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String {
|
||
calls.append("\(api).\(method)")
|
||
if api == "proc", method == "wait" {
|
||
return ExtensionRuntime.jsonString(
|
||
from: try await ExtensionAsyncProcess.wait(arguments.first))
|
||
}
|
||
if api == "fetch" {
|
||
return ExtensionRuntime.jsonString(from: try await fetcher.request(arguments.first))
|
||
}
|
||
if api == "websocket" {
|
||
return ExtensionRuntime.jsonString(
|
||
from: try await sockets.perform(method: method, arguments: arguments))
|
||
}
|
||
if api == "dns" {
|
||
return ExtensionRuntime.jsonString(from: await ExtensionNameResolver.resolve(arguments.first))
|
||
}
|
||
switch "\(api).\(method)" {
|
||
case "feedback.showToast":
|
||
toasts.append(arguments.first?.objectValue?["title"]?.stringValue ?? "")
|
||
return "1"
|
||
case "feedback.showHUD":
|
||
huds.append(arguments.first?.stringValue ?? "")
|
||
return ""
|
||
case "storage.get", "clipboard.readText":
|
||
return #""""#
|
||
case "storage.all":
|
||
return "{}"
|
||
case "system.frontmostApplication":
|
||
return
|
||
#"{"name":"Finder","path":"/System/Library/CoreServices/Finder.app","bundleId":"com.apple.finder"}"#
|
||
case "system.applications":
|
||
return "[]"
|
||
case "oauth.authorize":
|
||
let state = arguments[safe: 1]?.stringValue ?? ""
|
||
return "{\"authorizationCode\":\"auth_code_swift_test\",\"state\":\"\(state)\"}"
|
||
case "oauth.getTokens":
|
||
let providerId = arguments.first?.stringValue ?? ""
|
||
return oauthTokens[providerId] ?? ""
|
||
case "oauth.setTokens":
|
||
let providerId = arguments.first?.stringValue ?? ""
|
||
let tokens = arguments[safe: 1]?.stringValue ?? ""
|
||
oauthTokens[providerId] = tokens
|
||
return ""
|
||
case "oauth.removeTokens":
|
||
let providerId = arguments.first?.stringValue ?? ""
|
||
oauthTokens.removeValue(forKey: providerId)
|
||
return ""
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func sessionEnded() {
|
||
sockets.closeAll()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class Recorder: ExtensionRuntimeDelegate {
|
||
var trees: [RenderTree] = []
|
||
var failures: [String] = []
|
||
var logs: [String] = []
|
||
var finished = false
|
||
|
||
func runtime(_ runtime: ExtensionRuntime, session: String, didRender tree: RenderTree) {
|
||
trees.append(tree)
|
||
}
|
||
func runtime(_ runtime: ExtensionRuntime, session: String, didFail message: String) {
|
||
failures.append(message)
|
||
}
|
||
func runtime(_ runtime: ExtensionRuntime, session: String, navigationDepth: Int) {}
|
||
func runtime(_ runtime: ExtensionRuntime, session: String, didFinish: Void) { finished = true }
|
||
func runtime(_ runtime: ExtensionRuntime, log level: String, message: String) {
|
||
logs.append("[\(level)] \(message)")
|
||
}
|
||
}
|
||
|
||
/// The generated runtime, found relative to this source file's repository.
|
||
static func runtimeURL() -> URL {
|
||
let candidates = [
|
||
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||
.appendingPathComponent("Tinycast/Resources/RaycastRuntime.generated.js"),
|
||
URL(fileURLWithPath: #filePath)
|
||
.deletingLastPathComponent().deletingLastPathComponent()
|
||
.appendingPathComponent("Tinycast/Resources/RaycastRuntime.generated.js")
|
||
]
|
||
return candidates.first { FileManager.default.fileExists(atPath: $0.path) } ?? candidates[0]
|
||
}
|
||
|
||
@MainActor
|
||
static func makeRuntime() -> (ExtensionRuntime, StubHost, Recorder) {
|
||
let host = StubHost()
|
||
let recorder = Recorder()
|
||
let runtime = ExtensionRuntime(hostAPI: host, runtimeURL: runtimeURL())
|
||
runtime.setDelegate(recorder)
|
||
return (runtime, host, recorder)
|
||
}
|
||
|
||
static func launchContext(
|
||
extensionName: String = "fixture", command: String = "fixture",
|
||
mode: ExtensionCommandMode = .view, assets: String = "/tmp",
|
||
preferences: [String: ExtensionPreferenceValue] = [:],
|
||
arguments: [String: String] = [:], isDarkAppearance: Bool = true
|
||
) -> ExtensionLaunchContext {
|
||
ExtensionLaunchContext(
|
||
extensionName: extensionName, extensionTitle: extensionName, commandName: command,
|
||
commandMode: mode, assetsPath: assets, supportPath: "/tmp",
|
||
preferences: preferences, caches: [:], arguments: arguments, fallbackText: nil,
|
||
isDarkAppearance: isDarkAppearance)
|
||
}
|
||
|
||
/// `EXT_TEST_ARGS="hours=0,minutes=5"` — stands in for the palette's inline argument fields.
|
||
static func environmentArguments() -> [String: String] {
|
||
guard let raw = ProcessInfo.processInfo.environment["EXT_TEST_ARGS"] else { return [:] }
|
||
var arguments: [String: String] = [:]
|
||
for pair in raw.split(separator: ",") {
|
||
let parts = pair.split(separator: "=", maxSplits: 1)
|
||
guard parts.count == 2 else { continue }
|
||
arguments[String(parts[0])] = String(parts[1])
|
||
}
|
||
return arguments
|
||
}
|
||
|
||
/// `EXT_TEST_PREFS` as JSON — strings and bools, what a manifest preference holds.
|
||
static func environmentPreferences() -> [String: ExtensionPreferenceValue] {
|
||
guard let raw = ProcessInfo.processInfo.environment["EXT_TEST_PREFS"],
|
||
let json = try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String: Any]
|
||
else { return [:] }
|
||
return json.compactMapValues { value in
|
||
if let flag = value as? Bool { return .bool(flag) }
|
||
if let text = value as? String { return .string(text) }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// Let the JS event loop and the main-actor host hops settle.
|
||
static func settle(_ milliseconds: UInt64 = 250) async {
|
||
try? await Task.sleep(nanoseconds: milliseconds * 1_000_000)
|
||
}
|
||
|
||
// MARK: - Results
|
||
|
||
nonisolated(unsafe) static var failures = 0
|
||
nonisolated(unsafe) static var passes = 0
|
||
|
||
static func check(_ label: String, _ condition: Bool, _ detail: String = "") {
|
||
if condition {
|
||
passes += 1
|
||
} else {
|
||
failures += 1
|
||
print("FAIL \(label)\(detail.isEmpty ? "" : "\n \(detail)")")
|
||
}
|
||
}
|
||
|
||
// MARK: - Checks
|
||
|
||
@MainActor
|
||
static func runChecks() async {
|
||
manifestChecks()
|
||
renderNodeChecks()
|
||
screenChecks()
|
||
actionIconChecks()
|
||
oauthUnitChecks()
|
||
deepLinkChecks()
|
||
nodeShimChecks()
|
||
await runtimeChecks()
|
||
await searchAccessoryRuntimeChecks()
|
||
await nodeContractChecks()
|
||
await asyncComponentChecks()
|
||
|
||
print("\n\(passes) passed, \(failures) failed")
|
||
exit(failures == 0 ? 0 : 1)
|
||
}
|
||
|
||
static func nodeShimChecks() {
|
||
let result = ExtensionNodeShims().perform(api: "os", method: "cpus", argsJSON: "[]")
|
||
guard
|
||
let data = result.data(using: .utf8),
|
||
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
envelope["ok"] as? Bool == true,
|
||
let processors = envelope["value"] as? [[String: Any]]
|
||
else {
|
||
check("os.cpus host call succeeds", false, result)
|
||
return
|
||
}
|
||
|
||
check(
|
||
"os.cpus returns every processor",
|
||
processors.count == ProcessInfo.processInfo.processorCount,
|
||
"\(processors.count)")
|
||
let expectedStates = Set(["user", "nice", "sys", "idle", "irq"])
|
||
let valid = processors.allSatisfy { processor in
|
||
guard
|
||
processor["model"] is String,
|
||
processor["speed"] is NSNumber,
|
||
let times = processor["times"] as? [String: NSNumber],
|
||
Set(times.keys) == expectedStates
|
||
else { return false }
|
||
return times.values.allSatisfy { $0.doubleValue.isFinite && $0.doubleValue >= 0 }
|
||
}
|
||
check("os.cpus returns finite Node timing fields", valid, result)
|
||
|
||
func value(_ method: String) -> Any? {
|
||
let result = ExtensionNodeShims().perform(api: "os", method: method, argsJSON: "[]")
|
||
guard let data = result.data(using: .utf8),
|
||
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
envelope["ok"] as? Bool == true
|
||
else { return nil }
|
||
return envelope["value"]
|
||
}
|
||
let uptime = (value("uptime") as? NSNumber)?.doubleValue
|
||
check("os.uptime returns the system uptime", uptime.map { $0 > 0 } == true)
|
||
let freeMemory = (value("freemem") as? NSNumber)?.doubleValue
|
||
check(
|
||
"os.freemem returns finite bytes",
|
||
freeMemory.map { $0.isFinite && $0 >= 0 && $0 <= Double(ProcessInfo.processInfo.physicalMemory) }
|
||
== true)
|
||
let loadAverages = value("loadavg") as? [NSNumber]
|
||
check(
|
||
"os.loadavg returns three finite values",
|
||
loadAverages?.count == 3
|
||
&& loadAverages?.allSatisfy { $0.doubleValue.isFinite && $0.doubleValue >= 0 } == true)
|
||
}
|
||
|
||
static func manifestChecks() {
|
||
let json: [String: Any] = [
|
||
"name": "demo", "title": "Demo", "description": "d", "author": "a",
|
||
"icon": "icon.png", "platforms": ["macOS", "Windows"],
|
||
"preferences": [
|
||
["name": "token", "type": "password", "required": true],
|
||
// A platform-keyed default must resolve to the macOS value.
|
||
[
|
||
"name": "socket", "type": "textfield",
|
||
"default": ["macOS": "/var/run/x.sock", "Windows": "\\\\pipe"]
|
||
],
|
||
["name": "flag", "type": "checkbox", "title": "Flag"],
|
||
[
|
||
"name": "mode", "type": "dropdown", "default": "b",
|
||
"data": [["title": "A", "value": "a"], ["title": "B", "value": "b"]]
|
||
]
|
||
],
|
||
"commands": [
|
||
["name": "search", "title": "Search", "mode": "view", "keywords": ["find"]],
|
||
["name": "toggle", "title": "Toggle", "mode": "no-view"],
|
||
["name": "bar", "title": "Bar", "mode": "menu-bar"],
|
||
[
|
||
"name": "args", "title": "Args", "mode": "view",
|
||
"arguments": [["name": "q", "required": true, "placeholder": "Query"]]
|
||
]
|
||
]
|
||
]
|
||
guard let manifest = ExtensionManifest(json: json) else {
|
||
check("manifest parses", false)
|
||
return
|
||
}
|
||
check("manifest parses", true)
|
||
check("title", manifest.title == "Demo")
|
||
check("supports macOS", manifest.supportsMacOS)
|
||
check("commands", manifest.commands.count == 4, "\(manifest.commands.count)")
|
||
check("view mode", manifest.commands[0].mode == .view)
|
||
check("no-view mode", manifest.commands[1].mode == .noView)
|
||
check("menu-bar is unsupported", manifest.commands[2].mode.isSupported == false)
|
||
check("menu-bar explains itself", manifest.commands[2].mode.unsupportedReason != nil)
|
||
// Extensions branch on `environment.appearance`, so the host must not report a fixed one.
|
||
check(
|
||
"a dark host reports dark",
|
||
launchContext(isDarkAppearance: true).jsonString().contains("\"appearance\":\"dark\""))
|
||
check(
|
||
"a light host reports light",
|
||
launchContext(isDarkAppearance: false).jsonString().contains("\"appearance\":\"light\""))
|
||
check("keywords", manifest.commands[0].keywords == ["find"])
|
||
check("arguments", manifest.commands[3].arguments.first?.name == "q")
|
||
check("argument required", manifest.commands[3].arguments.first?.required == true)
|
||
// A blank optional argument arrives as "": `Number(args.x)` is NaN for undefined.
|
||
check(
|
||
"unfilled arguments are completed to empty strings",
|
||
manifest.commands[3].completeArguments([:]) == ["q": ""],
|
||
String(describing: manifest.commands[3].completeArguments([:])))
|
||
check(
|
||
"provided arguments survive completion",
|
||
manifest.commands[3].completeArguments(["q": "hi"]) == ["q": "hi"])
|
||
|
||
let prefs = Dictionary(uniqueKeysWithValues: manifest.preferences.map { ($0.name, $0) })
|
||
check("password kind", prefs["token"]?.kind == .password)
|
||
check("required flagged", prefs["token"]?.required == true)
|
||
check(
|
||
"platform-keyed default resolves to macOS",
|
||
prefs["socket"]?.defaultValue == .string("/var/run/x.sock"),
|
||
String(describing: prefs["socket"]?.defaultValue))
|
||
check(
|
||
"checkbox with no default is false",
|
||
prefs["flag"]?.effectiveDefault == .bool(false),
|
||
String(describing: prefs["flag"]?.effectiveDefault))
|
||
check("dropdown options", prefs["mode"]?.options.count == 2)
|
||
check("dropdown default", prefs["mode"]?.effectiveDefault == .string("b"))
|
||
|
||
// A manifest with no commands isn't an extension Tinycast can run.
|
||
check("rejects a manifest with no commands", ExtensionManifest(json: ["name": "x"]) == nil)
|
||
check(
|
||
"rejects a Windows-only manifest",
|
||
ExtensionManifest(
|
||
json: [
|
||
"name": "w", "platforms": ["Windows"],
|
||
"commands": [["name": "c", "title": "C"]]
|
||
])?.supportsMacOS == false)
|
||
|
||
// Launcher round-trip: an entry id must decode back to the same command.
|
||
let reference = ExtensionCommandRef(extensionName: "@scope/demo", commandName: "search")
|
||
let decoded = ExtensionCommandRef(entryID: reference.entryID)
|
||
check(
|
||
"command ref round-trips", decoded == reference,
|
||
"\(reference.entryID) → \(String(describing: decoded))")
|
||
check(
|
||
"non-extension entry id is rejected",
|
||
ExtensionCommandRef(entryID: "/Applications/Mail.app") == nil)
|
||
}
|
||
|
||
static func renderNodeChecks() {
|
||
let json = """
|
||
{"children":[{"id":1,"type":"__screen","props":{"active":true},"children":[
|
||
{"id":2,"type":"List","props":{"isLoading":false,"filtering":true,
|
||
"actions":{"id":9,"type":"ActionPanel","props":{},"children":[]}},
|
||
"children":[
|
||
{"id":3,"type":"List.Item","props":{
|
||
"title":"Row","subtitle":"sub","keywords":["kw"],
|
||
"icon":{"source":"circle-16","tintColor":"raycast-green"},
|
||
"accessories":[{"text":"3"},{"tag":{"value":"live","color":"#ff0000"}}],
|
||
"due":{"$date":"2026-01-02T03:04:05.678Z"},
|
||
"actions":{"id":4,"type":"ActionPanel","props":{},"children":[
|
||
{"id":5,"type":"Action","props":{"title":"Go","onAction":{"$fn":"5:onAction"},
|
||
"shortcut":{"modifiers":["cmd","shift"],"key":"g"}},"children":[]},
|
||
{"id":6,"type":"ActionPanel.Section","props":{"title":"More"},"children":[
|
||
{"id":7,"type":"Action","props":{"title":"Nested","style":"destructive"},"children":[]}]}]}},
|
||
"children":[]}]}]}]}
|
||
"""
|
||
guard let tree = RenderTree(json: json) else {
|
||
check("render tree decodes", false)
|
||
return
|
||
}
|
||
check("render tree decodes", true)
|
||
check("one screen", tree.screens.count == 1)
|
||
check("active screen resolves", tree.active?.bool("active") == true)
|
||
check("active root is the List", tree.activeRoot?.type == "List")
|
||
|
||
guard let list = tree.activeRoot, let item = list.children.first else {
|
||
check("list has an item", false)
|
||
return
|
||
}
|
||
check("list has an item", true)
|
||
check("string prop", item.string("title") == "Row")
|
||
check("bool prop", list.bool("filtering") == true)
|
||
check("array prop", item.array("keywords").compactMap(\.stringValue) == ["kw"])
|
||
check("nested object prop", item.object("icon")?["source"]?.stringValue == "circle-16")
|
||
check("accessories decode", item.array("accessories").count == 2)
|
||
// 2026-01-02T03:04:05.678Z
|
||
let expectedDue = DateComponents(
|
||
calendar: Calendar(identifier: .gregorian), timeZone: TimeZone(identifier: "UTC"),
|
||
year: 2026, month: 1, day: 2, hour: 3, minute: 4, second: 5
|
||
).date!
|
||
check(
|
||
"date prop revives",
|
||
item.date("due").map { abs($0.timeIntervalSince(expectedDue) - 0.678) < 0.01 } == true,
|
||
String(describing: item.date("due")))
|
||
check("slot prop becomes a node", item.node("actions")?.type == "ActionPanel")
|
||
check("screen-level actions slot", list.node("actions")?.id == 9)
|
||
|
||
// A number that happens to be whole must not read back as "3.0".
|
||
check(
|
||
"whole numbers stringify without a decimal",
|
||
RenderValue.number(3).stringValue == "3", RenderValue.number(3).stringValue ?? "nil")
|
||
check("bools aren't numbers", RenderValue.bool(true).boolValue == true)
|
||
check(
|
||
"a plain object prop isn't mistaken for a node",
|
||
RenderValue(json: ["type": "day", "min": 1] as [String: Any]).nodeValue == nil)
|
||
|
||
let actions = ExtensionScreen.actions(in: item.node("actions"))
|
||
check("actions flatten across sections", actions.count == 2, "\(actions.count)")
|
||
check("first action title", actions.first?.title == "Go")
|
||
check("handler id survives", actions.first?.handler == "5:onAction")
|
||
check(
|
||
"shortcut renders as keycaps", actions.first?.shortcutCaps == ["⌘", "⇧", "G"],
|
||
String(describing: actions.first?.shortcutCaps))
|
||
check("loose action starts no section", actions.first?.startsSection == false)
|
||
check("a section after loose actions starts one", actions.last?.startsSection == true)
|
||
check("destructive style", actions.last?.isDestructive == true)
|
||
sectionBoundaryChecks()
|
||
submenuPrimaryActionChecks()
|
||
}
|
||
|
||
/// Boundaries follow section nodes: Raycast authors mostly leave sections untitled.
|
||
static func sectionBoundaryChecks() {
|
||
func action(_ id: Int) -> String {
|
||
#"{"id":\#(id),"type":"Action","props":{"title":"A\#(id)"},"children":[]}"#
|
||
}
|
||
let json = """
|
||
{"id":1,"type":"ActionPanel","props":{},"children":[
|
||
{"id":2,"type":"ActionPanel.Section","props":{},"children":[
|
||
\(action(3)),
|
||
{"id":4,"type":"ActionPanel.Submenu","props":{"title":"Share"},"children":[\(action(5))]},
|
||
\(action(6))]},
|
||
{"id":7,"type":"ActionPanel.Section","props":{},"children":[]},
|
||
{"id":8,"type":"ActionPanel.Section","props":{},"children":[\(action(9))]},
|
||
{"id":10,"type":"ActionPanel.Section","props":{"title":"Same"},"children":[\(action(11))]},
|
||
{"id":12,"type":"ActionPanel.Section","props":{"title":"Same"},"children":[\(action(13))]},
|
||
\(action(14))]}
|
||
"""
|
||
guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any],
|
||
let panel = RenderNode(json: object)
|
||
else {
|
||
check("section fixture decodes", false)
|
||
return
|
||
}
|
||
let starts = ExtensionScreen.actions(in: panel).map(\.startsSection)
|
||
check(
|
||
"separators follow section nodes, not titles",
|
||
starts == [false, false, false, true, true, true, true], "\(starts)")
|
||
}
|
||
|
||
/// A submenu reached first must not become ⏎'s target as though it were its own child. #783.
|
||
static func submenuPrimaryActionChecks() {
|
||
func action(_ id: Int) -> String {
|
||
#"{"id":\#(id),"type":"Action","props":{"title":"A\#(id)"},"children":[]}"#
|
||
}
|
||
let json = """
|
||
{"id":1,"type":"ActionPanel","props":{},"children":[
|
||
{"id":2,"type":"ActionPanel.Submenu","props":{"title":"Open…"},"children":[
|
||
\(action(3)),
|
||
\(action(4))]},
|
||
{"id":5,"type":"ActionPanel.Section","props":{"title":"Other"},"children":[\(action(6))]}]}
|
||
"""
|
||
guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any],
|
||
let panel = RenderNode(json: object)
|
||
else {
|
||
check("submenu fixture decodes", false)
|
||
return
|
||
}
|
||
let actions = ExtensionScreen.actions(in: panel)
|
||
check(
|
||
"an action reached through a submenu carries its title",
|
||
actions.first?.enclosingSubmenuTitle == "Open…",
|
||
String(describing: actions.first?.enclosingSubmenuTitle))
|
||
check(
|
||
"the submenu's own leaves still flatten into the palette",
|
||
actions.map(\.title) == ["A3", "A4", "A6"], "\(actions.map(\.title))")
|
||
check(
|
||
"an action outside any submenu carries no submenu title",
|
||
actions.last?.enclosingSubmenuTitle == nil,
|
||
String(describing: actions.last?.enclosingSubmenuTitle))
|
||
|
||
// A loose action reached without ever entering a submenu is unaffected: primary fires it.
|
||
let looseFirstJSON = """
|
||
{"id":1,"type":"ActionPanel","props":{},"children":[
|
||
\(action(2)),
|
||
{"id":3,"type":"ActionPanel.Submenu","props":{"title":"Share"},"children":[\(action(4))]}]}
|
||
"""
|
||
guard
|
||
let looseObject = try? JSONSerialization.jsonObject(with: Data(looseFirstJSON.utf8))
|
||
as? [String: Any],
|
||
let loosePanel = RenderNode(json: looseObject)
|
||
else {
|
||
check("loose-first fixture decodes", false)
|
||
return
|
||
}
|
||
let looseActions = ExtensionScreen.actions(in: loosePanel)
|
||
check(
|
||
"a loose action ahead of any submenu keeps the primary a direct action",
|
||
looseActions.first?.enclosingSubmenuTitle == nil,
|
||
String(describing: looseActions.first?.enclosingSubmenuTitle))
|
||
|
||
// Mirrors ExtensionCommandScreen.primaryActionTitle/activate(at:), unreachable from here.
|
||
func primaryActionOutcome(_ actions: [ExtensionAction]) -> (title: String, opensPanel: Bool) {
|
||
guard let primary = actions.first else { return ("Run", false) }
|
||
return (
|
||
primary.enclosingSubmenuTitle ?? primary.title,
|
||
primary.enclosingSubmenuTitle != nil
|
||
)
|
||
}
|
||
|
||
let submenuOutcome = primaryActionOutcome(actions)
|
||
check(
|
||
"a submenu-backed primary's title is the submenu's, not the leaf's",
|
||
submenuOutcome.title == "Open…", submenuOutcome.title)
|
||
check(
|
||
"⏎ on a submenu-backed primary opens the actions panel instead of dispatching",
|
||
submenuOutcome.opensPanel, "\(submenuOutcome)")
|
||
|
||
let looseOutcome = primaryActionOutcome(looseActions)
|
||
check(
|
||
"a loose primary's title is its own leaf's",
|
||
looseOutcome.title == "A2", looseOutcome.title)
|
||
check(
|
||
"⏎ on a loose primary dispatches directly, since it never opens the panel",
|
||
!looseOutcome.opensPanel, "\(looseOutcome)")
|
||
}
|
||
|
||
static func screenChecks() {
|
||
func tree(_ children: String) -> RenderTree {
|
||
RenderTree(
|
||
json: """
|
||
{"children":[{"id":1,"type":"__screen","props":{"active":true},"children":[\(children)]}]}
|
||
""")!
|
||
}
|
||
|
||
let listJSON = """
|
||
{"id":2,"type":"List","props":{"filtering":true,"selectedItemId":"banana","searchBarPlaceholder":"Find…",
|
||
"onSelectionChange":{"$fn":"2:onSelectionChange"}},"children":[
|
||
{"id":3,"type":"List.Section","props":{"title":"Alpha","subtitle":"two"},"children":[
|
||
{"id":4,"type":"List.Item","props":{"id":"apple","title":"Apple"},"children":[]},
|
||
{"id":5,"type":"List.Item","props":{"id":"banana","title":"Banana"},"children":[]}]},
|
||
{"id":6,"type":"List.Item","props":{"id":"cherry","title":"Cherry","keywords":["red"]},"children":[]}]}
|
||
"""
|
||
let list = ExtensionScreen(tree: tree(listJSON), query: "")
|
||
check("kind is list", list.kind == .list)
|
||
check("placeholder", list.searchPlaceholder == "Find…")
|
||
check("filters locally", list.filtersLocally)
|
||
check("selected item id", list.selectedItemID == "banana")
|
||
check("selected item index", list.selectedItemIndex == 1)
|
||
check(
|
||
"items flattened in order",
|
||
list.items.map { $0.node.string("title") } == ["Apple", "Banana", "Cherry"])
|
||
check("rows interleave the section header", list.rows.count == 4, "\(list.rows.count)")
|
||
check(
|
||
"selection callback resolves the item id",
|
||
list.selectionChange(at: 1)
|
||
== .init(handler: "2:onSelectionChange", itemID: "banana"))
|
||
if case .header(let title, let subtitle, _) = list.rows.first {
|
||
check("header title", title == "Alpha")
|
||
check("header subtitle", subtitle == "two")
|
||
} else {
|
||
check("first row is a header", false)
|
||
}
|
||
|
||
// Filtering keeps row order and drops now-empty sections along with their header.
|
||
let filtered = ExtensionScreen(tree: tree(listJSON), query: "an")
|
||
check(
|
||
"filter matches title and keyword",
|
||
filtered.items.map { $0.node.string("title") } == ["Banana"],
|
||
String(describing: filtered.items.map { $0.node.string("title") }))
|
||
check("empty section drops its header", filtered.rows.count == 2, "\(filtered.rows.count)")
|
||
check(
|
||
"filtered selection resolves after filtering",
|
||
filtered.selectionChange(at: 0)
|
||
== .init(handler: "2:onSelectionChange", itemID: "banana"))
|
||
check("filtered selected item index", filtered.selectedItemIndex == 0)
|
||
check(
|
||
"an empty selection reports null",
|
||
filtered.selectionChange(at: 1)
|
||
== .init(handler: "2:onSelectionChange", itemID: nil))
|
||
let byKeyword = ExtensionScreen(tree: tree(listJSON), query: "red")
|
||
check("keyword match", byKeyword.items.map { $0.node.string("title") } == ["Cherry"])
|
||
|
||
// A command that owns the search text must not be filtered behind its back.
|
||
let controlled = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"List","props":{"onSearchTextChange":{"$fn":"2:onSearchTextChange"}},"children":[
|
||
{"id":3,"type":"List.Item","props":{"title":"Apple"},"children":[]}]}
|
||
"""), query: "zzz")
|
||
check("onSearchTextChange disables local filtering", controlled.filtersLocally == false)
|
||
check("controlled rows survive a non-matching query", controlled.items.count == 1)
|
||
check("search handler exposed", controlled.searchTextHandler == "2:onSearchTextChange")
|
||
|
||
let keepOrder = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"List","props":{"filtering":{"keepSectionOrder":true},
|
||
"onSearchTextChange":{"$fn":"2:onSearchTextChange"}},"children":[
|
||
{"id":3,"type":"List.Item","props":{"title":"Apple"},"children":[]},
|
||
{"id":4,"type":"List.Item","props":{"title":"Banana"},"children":[]}]}
|
||
"""), query: "ban")
|
||
check("an object `filtering` still filters", keepOrder.filtersLocally)
|
||
check(
|
||
"and keeps only the match",
|
||
keepOrder.items.map { $0.node.string("title") } == ["Banana"],
|
||
keepOrder.items.map { $0.node.string("title") ?? "" }.joined(separator: ","))
|
||
|
||
let grid = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"Grid","props":{"columns":4},"children":[
|
||
{"id":3,"type":"Grid.Item","props":{"title":"One"},"children":[]}]}
|
||
"""), query: "")
|
||
check(
|
||
"kind is grid with columns", grid.kind == .grid(ExtensionGridLayout(columns: 4)),
|
||
String(describing: grid.kind))
|
||
|
||
let shaped = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"Grid","props":{"columns":3,"aspectRatio":"16/9","fit":"fill",
|
||
"inset":"lg"},"children":[
|
||
{"id":3,"type":"Grid.Item","props":{"title":"One"},"children":[]}]}
|
||
"""), query: "")
|
||
check(
|
||
"grid layout props parsed",
|
||
shaped.kind
|
||
== .grid(
|
||
ExtensionGridLayout(
|
||
columns: 3, aspectRatio: 16.0 / 9, fills: true, inset: .large)),
|
||
String(describing: shaped.kind))
|
||
|
||
let legacy = ExtensionScreen(
|
||
tree: tree(#"{"id":2,"type":"Grid","props":{"itemSize":"small"},"children":[]}"#),
|
||
query: "")
|
||
check("itemSize still sets columns", legacy.kind == .grid(ExtensionGridLayout(columns: 8)))
|
||
|
||
let layout = ExtensionGridLayout(columns: 5)
|
||
check(
|
||
"tile width divides the space",
|
||
layout.tileWidth(inWidth: 100, spacing: 5) == 16,
|
||
String(layout.tileWidth(inWidth: 100, spacing: 5)))
|
||
check("columns clamp to Raycast's range", ExtensionGridLayout(columns: 99).columns == 8)
|
||
check("a bad aspect ratio falls back to square", ExtensionGridLayout(aspectRatio: 0).aspectRatio == 1)
|
||
check("large inset insets a quarter of the tile", ExtensionGridLayout.Inset.large.fraction == 0.24)
|
||
|
||
let form = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"Form","props":{},"children":[
|
||
{"id":3,"type":"Form.TextField","props":{"id":"name","value":"Ada"},"children":[]},
|
||
{"id":4,"type":"Form.Separator","props":{},"children":[]}]}
|
||
"""), query: "")
|
||
check("kind is form", form.kind == .form)
|
||
check("fields collected", form.fields.count == 2)
|
||
check("only focusable fields are rows", form.items.count == 1)
|
||
check("the row is the field, not the separator", form.items.first?.node.id == 3)
|
||
check("a separator has no focus index", form.focusItem(for: form.fields[1]) == nil)
|
||
check(
|
||
"a text area keeps the vertical keys",
|
||
ExtensionFormField(type: "Form.TextArea").ownsVerticalKeys)
|
||
let detail = ExtensionScreen(
|
||
// Doubled delimiters: the heading contains `"#`, which closes a single-# string.
|
||
tree: tree(##"{"id":2,"type":"Detail","props":{"markdown":"# Hi"},"children":[]}"##),
|
||
query: "")
|
||
check("kind is detail", detail.kind == .detail)
|
||
|
||
let unsupported = ExtensionScreen(
|
||
tree: tree(#"{"id":2,"type":"MenuBarExtra","props":{},"children":[]}"#), query: "")
|
||
check(
|
||
"unknown root reported", unsupported.kind == .unsupported("MenuBarExtra"),
|
||
String(describing: unsupported.kind))
|
||
|
||
// An item's own panel wins; the screen's is the fallback.
|
||
let panels = ExtensionScreen(
|
||
tree: tree(
|
||
"""
|
||
{"id":2,"type":"List","props":{"actions":{"id":8,"type":"ActionPanel","props":{},"children":[]}},"children":[
|
||
{"id":3,"type":"List.Item","props":{"title":"A","actions":{"id":9,"type":"ActionPanel","props":{},"children":[]}},"children":[]},
|
||
{"id":4,"type":"List.Item","props":{"title":"B"},"children":[]}]}
|
||
"""), query: "")
|
||
check("item panel wins", panels.actionPanel(forItemAt: 0)?.id == 9)
|
||
check("screen panel is the fallback", panels.actionPanel(forItemAt: 1)?.id == 8)
|
||
check("out-of-range selection falls back", panels.actionPanel(forItemAt: 99)?.id == 8)
|
||
}
|
||
|
||
/// An `Action`'s icon is a full `ImageLike`, so the ⌘K panel has to keep its tint.
|
||
@MainActor
|
||
static func actionIconChecks() {
|
||
func icon(
|
||
_ json: String, isDestructive: Bool = false, assets: String? = nil
|
||
) -> ExtensionImage.Resolved {
|
||
let wrapped = Data(#"{"icon": \#(json)}"#.utf8)
|
||
let props = (try? JSONSerialization.jsonObject(with: wrapped)) as? [String: Any]
|
||
return ExtensionImage.actionIcon(
|
||
props?["icon"].map(RenderValue.init(json:)), assetsPath: assets, isDark: true,
|
||
isDestructive: isDestructive)
|
||
}
|
||
|
||
let tinted = icon(#"{"source":"circle-16","tintColor":"raycast-green"}"#)
|
||
check("a tinted symbol keeps its source", tinted.source == .symbol("circle"))
|
||
check("a tinted symbol keeps its tint", tinted.tint == .green)
|
||
|
||
// Doubled delimiters: the hex tint contains `"#`, which closes a single-# raw string.
|
||
check(
|
||
"raw hex tints too",
|
||
icon(##"{"source":"circle-16","tintColor":"#FF3B30"}"##).tint
|
||
== Color(red: 1, green: 0x3B / 255, blue: 0x30 / 255))
|
||
check(
|
||
"a themed tint picks the dark side",
|
||
icon(#"{"source":"circle-16","tintColor":{"light":"raycast-red","dark":"raycast-blue"}}"#)
|
||
.tint == .blue)
|
||
|
||
let bare = icon(#""checkmark-circle-16""#)
|
||
check("a bare icon still resolves", bare.source == .symbol("checkmark.circle"))
|
||
check("and carries no tint", bare.tint == nil)
|
||
|
||
let asset = icon(#""logo.png""#, assets: "/tmp/demo/assets")
|
||
check(
|
||
"an asset name resolves against assets/", asset.source == .file("/tmp/demo/assets/logo.png"),
|
||
String(describing: asset.source))
|
||
|
||
check("no icon falls back to a glyph", icon("null").source == .symbol("bolt"))
|
||
let destructive = icon("null", isDestructive: true)
|
||
check("a destructive fallback is a trash glyph", destructive.source == .symbol("trash"))
|
||
check("and takes red", destructive.tint == .red)
|
||
check(
|
||
"a destructive action's own tint wins",
|
||
icon(#"{"source":"circle-16","tintColor":"raycast-yellow"}"#, isDestructive: true).tint
|
||
== .yellow)
|
||
// A tint masks artwork rather than colouring it, so a destructive PNG must stay untinted.
|
||
let destructiveArtwork = icon(#""danger.png""#, isDestructive: true, assets: "/tmp/a")
|
||
check(
|
||
"a destructive artwork icon keeps its own colours",
|
||
destructiveArtwork.source == .file("/tmp/a/danger.png") && destructiveArtwork.tint == nil,
|
||
String(describing: destructiveArtwork))
|
||
}
|
||
|
||
private final class MockTokenStore: ExtensionOAuthTokenStore, @unchecked Sendable {
|
||
var storage: [String: String] = [:]
|
||
|
||
func get(account: String) -> String? {
|
||
storage[account]
|
||
}
|
||
|
||
func set(_ value: String, account: String) -> Bool {
|
||
storage[account] = value
|
||
return true
|
||
}
|
||
|
||
func remove(account: String) -> Bool {
|
||
storage.removeValue(forKey: account) != nil
|
||
}
|
||
|
||
func removeAll(prefix: String, exactMatch: String) {
|
||
storage = storage.filter { key, _ in
|
||
key != exactMatch && !key.hasPrefix(prefix)
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
static func oauthUnitChecks() {
|
||
let originalStore = ExtensionOAuthKeychain.store
|
||
ExtensionOAuthKeychain.store = MockTokenStore()
|
||
defer { ExtensionOAuthKeychain.store = originalStore }
|
||
|
||
// Keychain round-trip
|
||
let extName = "com.test.unit"
|
||
let provId = "unit_provider"
|
||
let json = "{\"accessToken\":\"token_xyz\",\"refreshToken\":\"refresh_abc\"}"
|
||
|
||
ExtensionOAuthKeychain.setTokens(json, extensionName: extName, providerId: provId)
|
||
let read = ExtensionOAuthKeychain.getTokens(extensionName: extName, providerId: provId)
|
||
check("OAuth Keychain sets and gets tokens", read == json, read ?? "nil")
|
||
|
||
ExtensionOAuthKeychain.removeTokens(extensionName: extName, providerId: provId)
|
||
let afterRemove = ExtensionOAuthKeychain.getTokens(extensionName: extName, providerId: provId)
|
||
check("OAuth Keychain removes tokens", afterRemove == nil, afterRemove ?? "not nil")
|
||
|
||
ExtensionOAuthKeychain.setTokens(json, extensionName: extName, providerId: "prov1")
|
||
ExtensionOAuthKeychain.setTokens(json, extensionName: extName, providerId: "prov2")
|
||
ExtensionOAuthKeychain.removeAllTokens(extensionName: extName)
|
||
let afterRemoveAll1 = ExtensionOAuthKeychain.getTokens(extensionName: extName, providerId: "prov1")
|
||
let afterRemoveAll2 = ExtensionOAuthKeychain.getTokens(extensionName: extName, providerId: "prov2")
|
||
check(
|
||
"OAuth Keychain removeAllTokens clears all for extension",
|
||
afterRemoveAll1 == nil && afterRemoveAll2 == nil)
|
||
|
||
// URL parsing in ExtensionOAuthSession
|
||
let raycastURL = URL(string: "raycast://oauth?code=auth_123&state=state_456")!
|
||
let params = ExtensionOAuthSession.parseCallback(url: raycastURL)
|
||
check(
|
||
"parseCallback parses query parameters",
|
||
params["code"] == "auth_123" && params["state"] == "state_456")
|
||
|
||
let fragmentURL = URL(string: "raycast://oauth#access_token=token_xyz&state=state_789")!
|
||
let fragParams = ExtensionOAuthSession.parseCallback(url: fragmentURL)
|
||
check(
|
||
"parseCallback parses hash fragment",
|
||
fragParams["access_token"] == "token_xyz" && fragParams["state"] == "state_789")
|
||
|
||
let nonOAuthURL = URL(string: "raycast://extensions/installed")!
|
||
check(
|
||
"handleCallbackURL ignores a non-oauth URL",
|
||
ExtensionOAuthSession.handleCallbackURL(nonOAuthURL) == .ignored)
|
||
|
||
// A callback with nothing waiting for it is reported, not silently dropped.
|
||
let strayURL = URL(string: "tinycast://oauth?code=abc&state=xyz")!
|
||
check(
|
||
"handleCallbackURL reports an expired callback",
|
||
ExtensionOAuthSession.handleCallbackURL(strayURL) == .expired)
|
||
}
|
||
|
||
static func deepLinkChecks() {
|
||
let canonical = ExtensionDeepLink.parse(
|
||
url: URL(string: "raycast://extensions/linear/linear/create-issue")!)
|
||
check(
|
||
"deeplink parses owner, extension and command",
|
||
canonical?.ownerOrAuthor == "linear" && canonical?.extensionName == "linear"
|
||
&& canonical?.commandName == "create-issue",
|
||
String(describing: canonical))
|
||
check(
|
||
"deeplink prefers the scoped manifest name",
|
||
canonical?.extensionCandidates == ["linear/linear", "linear"],
|
||
String(describing: canonical?.extensionCandidates))
|
||
|
||
let tiny = ExtensionDeepLink.parse(
|
||
url: URL(string: "tinycast://extensions/linear/linear/create-issue")!)
|
||
check("deeplink mirrors raycast:// as tinycast://", tiny == canonical)
|
||
|
||
let bare = ExtensionDeepLink.parse(url: URL(string: "raycast://extensions/demo/search")!)
|
||
check(
|
||
"deeplink without an owner parses",
|
||
bare?.ownerOrAuthor == nil && bare?.extensionName == "demo"
|
||
&& bare?.commandName == "search")
|
||
|
||
let args = ExtensionDeepLink.parse(
|
||
url: URL(
|
||
string:
|
||
"raycast://extensions/linear/linear/create-issue?arguments=%7B%22title%22%3A%22Triage%22%7D"
|
||
)!)
|
||
check(
|
||
"deeplink decodes arguments JSON",
|
||
args?.arguments == ["title": "Triage"], String(describing: args?.arguments))
|
||
|
||
let coerced = ExtensionDeepLink.parseArguments(#"{"q":"","n":3,"flag":true}"#)
|
||
check(
|
||
"deeplink coerces non-string arguments",
|
||
coerced == ["q": "", "n": "3", "flag": "true"], String(describing: coerced))
|
||
check(
|
||
"deeplink treats malformed arguments as none",
|
||
ExtensionDeepLink.parseArguments("not-json") == [:])
|
||
|
||
let full = ExtensionDeepLink.parse(
|
||
url: URL(
|
||
string: "raycast://extensions/demo/search?fallbackText=hello&launchType=background"
|
||
)!)
|
||
check(
|
||
"deeplink reads fallback text and background launch",
|
||
full?.fallbackText == "hello" && full?.launchType == .background)
|
||
|
||
let legacy = ExtensionDeepLink.parse(
|
||
url: URL(string: "com.raycast:/extensions/demo/search")!)
|
||
check(
|
||
"deeplink reads the com.raycast path form",
|
||
legacy?.extensionName == "demo" && legacy?.commandName == "search")
|
||
|
||
check(
|
||
"deeplink rejects a non-extensions link",
|
||
ExtensionDeepLink.parse(url: URL(string: "raycast://confetti")!) == nil)
|
||
check(
|
||
"deeplink rejects an OAuth callback",
|
||
ExtensionDeepLink.parse(url: URL(string: "raycast://oauth?code=abc")!) == nil)
|
||
check(
|
||
"deeplink rejects other schemes",
|
||
ExtensionDeepLink.parse(url: URL(string: "https://example.com/x")!) == nil)
|
||
|
||
check(
|
||
"deeplink matches a scoped install by slug",
|
||
bare?.matches(manifestName: "owner/demo") == true)
|
||
check(
|
||
"deeplink matches a short install from a scoped link",
|
||
canonical?.matches(manifestName: "linear") == true)
|
||
check(
|
||
"deeplink rejects another extension",
|
||
canonical?.matches(manifestName: "other/other") == false)
|
||
}
|
||
|
||
// MARK: - End-to-end through JavaScriptCore
|
||
|
||
@MainActor
|
||
static func runtimeChecks() async {
|
||
let runtimeFile = runtimeURL()
|
||
guard FileManager.default.fileExists(atPath: runtimeFile.path) else {
|
||
check("RaycastRuntime.generated.js exists", false, runtimeFile.path)
|
||
return
|
||
}
|
||
check("RaycastRuntime.generated.js exists", true)
|
||
|
||
let (runtime, host, recorder) = makeRuntime()
|
||
do {
|
||
try await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
check("runtime boots in JavaScriptCore", true)
|
||
} catch {
|
||
check("runtime boots in JavaScriptCore", false, error.localizedDescription)
|
||
return
|
||
}
|
||
|
||
// A synthetic command exercising React state, the node shims, timers and host calls.
|
||
let command = """
|
||
"use strict";
|
||
const { List, ActionPanel, Action, Icon, showToast, Toast } = require("@raycast/api");
|
||
const React = require("react");
|
||
const path = require("node:path");
|
||
const os = require("node:os");
|
||
const crypto = require("node:crypto");
|
||
const { fileURLToPath, pathToFileURL } = require("node:url");
|
||
const util = require("node:util");
|
||
const h = React.createElement;
|
||
module.exports.default = function Command() {
|
||
const [count, setCount] = React.useState(0);
|
||
const [derived, setDerived] = React.useState("pending");
|
||
React.useEffect(() => {
|
||
const timer = setTimeout(() => setCount(1), 20);
|
||
crypto.pbkdf2("foobar", "foobarbazzybaz", 1e5, 64, "sha512", (error, key) =>
|
||
setDerived(error ? error.message : key.toString("hex").slice(0, 16)));
|
||
showToast({ style: Toast.Style.Success, title: "hello" });
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
const digest = crypto.createHash("sha256").update("abc").digest("hex").slice(0, 8);
|
||
const cpu = os.cpus()[0];
|
||
const cpuTimes = Object.values(cpu.times).every(Number.isFinite) ? "cpu=ok" : "cpu=bad";
|
||
// AbortSignal's statics, the brand node-fetch checks, and url.parse's legacy `path`.
|
||
const abortable = [
|
||
typeof AbortSignal.timeout, typeof AbortSignal.abort, typeof AbortSignal.any,
|
||
String(AbortSignal.timeout(5e3).aborted), AbortSignal.abort().reason.name,
|
||
Object.getPrototypeOf(AbortSignal.abort()).constructor.name,
|
||
Object.prototype.toString.call(AbortSignal.abort()),
|
||
require("node:url").parse("https://a.test/ajax.php?f=list").path,
|
||
].join(",");
|
||
const errorCode = (callback) => {
|
||
try { callback(); return "none"; } catch (error) { return error.code; }
|
||
};
|
||
const filePaths = [
|
||
fileURLToPath("file:///Applications/Tinycast%20Beta.app"),
|
||
fileURLToPath(pathToFileURL("/tmp/a#b.png")),
|
||
pathToFileURL("/tmp/My Image.png").href,
|
||
errorCode(() => fileURLToPath("file:///tmp/a%2Fb")),
|
||
errorCode(() => fileURLToPath("file://example.com/tmp/a")),
|
||
errorCode(() => fileURLToPath("https://example.com/a")),
|
||
].join("\\n");
|
||
// execa and undici read all of these at module scope; each was once a TypeError.
|
||
const debug = util.debuglog("execa");
|
||
const utilShim = [
|
||
typeof debug, String(debug.enabled), String(debug("ignored")),
|
||
util.stripVTControlCharacters("\\u001B[31mred\\u001B[39m"),
|
||
util.formatWithOptions({ colors: true }, "%s=%d", "n", 2),
|
||
String(util.inspect.custom === Symbol.for("nodejs.util.inspect.custom")),
|
||
typeof util.aborted(AbortSignal.abort()).then,
|
||
].join(",");
|
||
// Bitwarden derives its session hash and caches the vault through exactly these calls.
|
||
const encrypter = crypto.createCipheriv("aes-256-cbc", "k".repeat(32), "i".repeat(16));
|
||
const encrypted = Buffer.concat([encrypter.update("hello tinycast"), encrypter.final()]);
|
||
const decrypter = crypto.createDecipheriv("aes-256-cbc", "k".repeat(32), Buffer.from("i".repeat(16)));
|
||
const ecb = crypto.createCipheriv("aes-128-ecb", Buffer.alloc(16, 1), null).setAutoPadding(false);
|
||
const cipherShim = [
|
||
crypto.pbkdf2Sync("password", "salt", 1000, 16, "sha512").toString("hex"),
|
||
crypto.pbkdf2Sync("", "", 1, 8, "SHA-256").toString("hex"),
|
||
crypto.createCipheriv("aes-256-cbc", "k".repeat(32), "i".repeat(16)).final("hex"),
|
||
encrypted.toString("hex"),
|
||
decrypter.update(encrypted.toString("hex"), "hex", "utf8") + decrypter.final("utf8"),
|
||
ecb.update(Buffer.alloc(16, 2)).toString("hex") + ecb.final("hex"),
|
||
errorCode(() => {
|
||
const wrong = crypto.createDecipheriv("aes-256-cbc", "k".repeat(32), "i".repeat(16));
|
||
wrong.update(Buffer.alloc(16));
|
||
wrong.final();
|
||
}),
|
||
errorCode(() => crypto.createCipheriv("aes-256-cbc", "short", "i".repeat(16))),
|
||
errorCode(() => crypto.pbkdf2Sync("p", "s", 1, 8, "nope")),
|
||
derived,
|
||
].join(",");
|
||
return h(List, { navigationTitle: "Synthetic", isLoading: false },
|
||
h(List.Item, {
|
||
title: "count=" + count,
|
||
subtitle: path.join("/a/b", "../c"),
|
||
icon: Icon.Circle,
|
||
accessories: [
|
||
{ text: digest }, { text: abortable }, { text: filePaths },
|
||
{ text: cpuTimes }, { text: cipherShim }, { text: utilShim },
|
||
],
|
||
actions: h(ActionPanel, null,
|
||
h(Action, { title: "Bump", onAction: () => setCount((v) => v + 10) }))
|
||
}));
|
||
};
|
||
"""
|
||
await runtime.start(
|
||
session: "s1", code: command, file: URL(fileURLWithPath: "/tmp/synthetic.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle()
|
||
|
||
check("no failures", recorder.failures.isEmpty, recorder.failures.joined(separator: "\n"))
|
||
check("rendered at least once", !recorder.trees.isEmpty)
|
||
|
||
guard var screen = recorder.trees.last.map({ ExtensionScreen(tree: $0, query: "") }) else {
|
||
check("screen builds from the live tree", false)
|
||
return
|
||
}
|
||
check("screen builds from the live tree", true)
|
||
check("navigation title", screen.navigationTitle == "Synthetic")
|
||
check(
|
||
"timer fired and re-rendered",
|
||
screen.items.first?.node.string("title") == "count=1",
|
||
screen.items.first?.node.string("title") ?? "nil")
|
||
check(
|
||
"node path shim", screen.items.first?.node.string("subtitle") == "/a/c",
|
||
screen.items.first?.node.string("subtitle") ?? "nil")
|
||
check(
|
||
"crypto shim",
|
||
ExtensionAccessoriesView_labelForTest(screen.items.first?.node.array("accessories").first)
|
||
== "ba7816bf",
|
||
String(describing: screen.items.first?.node.array("accessories").first))
|
||
check(
|
||
"os.cpus crosses the synchronous host bridge",
|
||
ExtensionAccessoriesView_labelForTest(
|
||
screen.items.first?.node.array("accessories").dropFirst(3).first) == "cpu=ok",
|
||
String(describing: screen.items.first?.node.array("accessories")))
|
||
check("toast reached the host", host.toasts == ["hello"], host.toasts.joined(separator: ","))
|
||
check(
|
||
"AbortSignal survives node-fetch's brand checks, and url.parse keeps its path",
|
||
ExtensionAccessoriesView_labelForTest(
|
||
screen.items.first?.node.array("accessories").dropFirst().first)
|
||
== "function,function,function,false,AbortError,AbortSignal,"
|
||
+ "[object AbortSignal],/ajax.php?f=list",
|
||
String(describing: screen.items.first?.node.array("accessories").dropFirst().first))
|
||
check(
|
||
"fileURLToPath decodes a path and rejects an unusable URL",
|
||
ExtensionAccessoriesView_labelForTest(
|
||
screen.items.first?.node.array("accessories").dropFirst(2).first)
|
||
== "/Applications/Tinycast Beta.app\n/tmp/a#b.png\n"
|
||
+ "file:///tmp/My%20Image.png\n"
|
||
+ "ERR_INVALID_FILE_URL_PATH\nERR_INVALID_FILE_URL_HOST\n"
|
||
+ "ERR_INVALID_URL_SCHEME",
|
||
String(describing: screen.items.first?.node.array("accessories").dropFirst(2).first))
|
||
check(
|
||
"crypto shim derives PBKDF2 keys and round-trips AES like Node",
|
||
ExtensionAccessoriesView_labelForTest(
|
||
screen.items.first?.node.array("accessories").dropFirst(4).first)
|
||
== "afe6c5530785b6cc6b1c6453384731bd,f7ce0b653d2d72a4,5d11c49af18b4b3e482508362bd2c857,"
|
||
+ "eb7b227687302ff167fef6a04d9f99f3,"
|
||
+ "hello tinycast,17d614f379a9359077e95577fd31c20a,ERR_OSSL_BAD_DECRYPT,"
|
||
+ "ERR_CRYPTO_INVALID_KEYLEN,ERR_CRYPTO_INVALID_DIGEST,6cba6dd1d44f53a3",
|
||
String(describing: screen.items.first?.node.array("accessories").dropFirst(4).first))
|
||
check(
|
||
"util shim answers debuglog, stripVTControlCharacters, aborted and inspect.custom",
|
||
ExtensionAccessoriesView_labelForTest(screen.items.first?.node.array("accessories").last)
|
||
== "function,false,undefined,red,n=2,true,function",
|
||
String(describing: screen.items.first?.node.array("accessories").last))
|
||
|
||
// Dispatch the row's action and confirm the re-render.
|
||
let actions = ExtensionScreen.actions(in: screen.actionPanel(forItemAt: 0))
|
||
check("action is dispatchable", actions.first?.handler != nil)
|
||
if let handler = actions.first?.handler {
|
||
await runtime.dispatch(
|
||
session: "s1", handler: handler,
|
||
payload: ExtensionRuntime.jsonString(from: []))
|
||
await settle()
|
||
screen = ExtensionScreen(tree: recorder.trees.last!, query: "")
|
||
check(
|
||
"action re-rendered the row",
|
||
screen.items.first?.node.string("title") == "count=11",
|
||
screen.items.first?.node.string("title") ?? "nil")
|
||
}
|
||
|
||
// OAuth PKCE and TokenSet runtime tests
|
||
let (oauthRuntime, oauthHost, oauthRecorder) = makeRuntime()
|
||
try? await oauthRuntime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
let oauthCommand = """
|
||
"use strict";
|
||
const { OAuth, showHUD } = require("@raycast/api");
|
||
module.exports.default = async function () {
|
||
const client = new OAuth.PKCEClient({
|
||
redirectMethod: OAuth.RedirectMethod.Web,
|
||
providerName: "GitHub",
|
||
providerId: "gh",
|
||
});
|
||
const req = await client.authorizationRequest({
|
||
endpoint: "https://github.com/login/oauth/authorize",
|
||
clientId: "id123",
|
||
});
|
||
const auth = await client.authorize(req);
|
||
const tokens = new OAuth.TokenSet({
|
||
accessToken: "token_" + auth.authorizationCode,
|
||
refreshToken: "refresh_123",
|
||
expiresIn: 3600,
|
||
});
|
||
await client.setTokens(tokens);
|
||
const read = await client.getTokens();
|
||
await showHUD(read.accessToken);
|
||
};
|
||
"""
|
||
await oauthRuntime.start(
|
||
session: "sOAuth", code: oauthCommand,
|
||
file: URL(fileURLWithPath: "/tmp/oauth.js"), mode: .noView,
|
||
context: launchContext(mode: .noView))
|
||
await settle()
|
||
check("oauth command finished", oauthRecorder.finished, oauthRecorder.failures.joined())
|
||
check(
|
||
"oauth flow reached token storage",
|
||
oauthHost.huds == ["token_auth_code_swift_test"],
|
||
oauthHost.huds.joined(separator: ","))
|
||
await oauthRuntime.stop(session: "sOAuth")
|
||
|
||
// Command arguments must reach `props.arguments`, and the bag must exist even when empty.
|
||
let (withArguments, _, argumentRecorder) = makeRuntime()
|
||
try? await withArguments.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
let argumentCommand = #"""
|
||
"use strict";
|
||
const { Detail } = require("@raycast/api");
|
||
const React = require("react");
|
||
module.exports.default = function (props) {
|
||
const bag = props.arguments;
|
||
return React.createElement(Detail, {
|
||
markdown: [bag.hours, bag.minutes, Object.keys(bag).length, props.launchType].join("|"),
|
||
});
|
||
};
|
||
"""#
|
||
await withArguments.start(
|
||
session: "sA", code: argumentCommand, file: URL(fileURLWithPath: "/tmp/args.js"),
|
||
mode: .view, context: launchContext(arguments: ["hours": "0", "minutes": "5"]))
|
||
await settle()
|
||
let argumentMarkdown = argumentRecorder.trees.last?.activeRoot?.string("markdown") ?? ""
|
||
check(
|
||
"launch arguments reach props.arguments", argumentMarkdown == "0|5|2|userInitiated",
|
||
argumentMarkdown)
|
||
await withArguments.stop(session: "sA")
|
||
|
||
// React's scheduler drives commits through `setTimeout`, shared across sessions.
|
||
let (pending, _, pendingRecorder) = makeRuntime()
|
||
try? await pending.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
let tickingCommand = #"""
|
||
"use strict";
|
||
const { List } = require("@raycast/api");
|
||
const React = require("react");
|
||
module.exports.default = function Command() {
|
||
const [tick, setTick] = React.useState(0);
|
||
React.useEffect(() => {
|
||
const id = setInterval(() => setTick((v) => v + 1), 40);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
return React.createElement(List, null,
|
||
React.createElement(List.Item, { key: "t", title: "tick=" + tick }));
|
||
};
|
||
"""#
|
||
await pending.start(
|
||
session: "p1", code: tickingCommand, file: URL(fileURLWithPath: "/tmp/tick.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle(70)
|
||
await pending.stop(session: "p1")
|
||
pending.shutdown()
|
||
let pendingRerun = Recorder()
|
||
pending.setDelegate(pendingRerun)
|
||
try? await pending.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
await pending.start(
|
||
session: "p2", code: tickingCommand, file: URL(fileURLWithPath: "/tmp/tick.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle(150)
|
||
check(
|
||
"a re-run after stopping mid-timer still renders",
|
||
!pendingRerun.trees.isEmpty,
|
||
"first run rendered \(pendingRecorder.trees.count)×; "
|
||
+ pendingRerun.failures.joined(separator: "|"))
|
||
await pending.stop(session: "p2")
|
||
|
||
await runtime.stop(session: "s1")
|
||
let rerunRecorder = Recorder()
|
||
runtime.setDelegate(rerunRecorder)
|
||
await runtime.start(
|
||
session: "s1b", code: command, file: URL(fileURLWithPath: "/tmp/synthetic.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle()
|
||
check(
|
||
"a second run in the same runtime renders",
|
||
!rerunRecorder.trees.isEmpty, rerunRecorder.failures.joined(separator: "|"))
|
||
check(
|
||
"the second run's timers still fire",
|
||
rerunRecorder.trees.last
|
||
.map { ExtensionScreen(tree: $0, query: "").items.first?.node.string("title") == "count=1" }
|
||
== true,
|
||
rerunRecorder.trees.last
|
||
.flatMap { ExtensionScreen(tree: $0, query: "").items.first?.node.string("title") }
|
||
?? "no tree")
|
||
await runtime.stop(session: "s1b")
|
||
runtime.setDelegate(recorder)
|
||
|
||
// A no-view command runs headless and reports completion.
|
||
let (headless, headlessHost, headlessRecorder) = makeRuntime()
|
||
try? await headless.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
await headless.start(
|
||
session: "s2",
|
||
code: """
|
||
"use strict";
|
||
const { showHUD } = require("@raycast/api");
|
||
module.exports.default = async function () { await showHUD("done"); };
|
||
""",
|
||
file: URL(fileURLWithPath: "/tmp/headless.js"), mode: .noView,
|
||
context: launchContext(mode: .noView))
|
||
await settle()
|
||
check("no-view command finished", headlessRecorder.finished, headlessRecorder.failures.joined())
|
||
check("no-view HUD reached the host", headlessHost.huds == ["done"])
|
||
await headless.stop(session: "s2")
|
||
|
||
// A throwing command surfaces its error rather than taking the runtime down.
|
||
let (failing, _, failingRecorder) = makeRuntime()
|
||
try? await failing.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
await failing.start(
|
||
session: "s3",
|
||
code: #"module.exports.default = function () { throw new Error("kaboom"); };"#,
|
||
file: URL(fileURLWithPath: "/tmp/bad.js"), mode: .view, context: launchContext())
|
||
await settle()
|
||
check(
|
||
"a throwing command reports a failure",
|
||
failingRecorder.failures.contains { $0.contains("kaboom") },
|
||
failingRecorder.failures.joined(separator: "|"))
|
||
await failing.stop(session: "s3")
|
||
|
||
await swiftHelperChecks()
|
||
await processKillChecks()
|
||
zlibChecks()
|
||
}
|
||
|
||
/// Drives a dropdown-filtered command from its empty first render to visible results.
|
||
@MainActor
|
||
static func searchAccessoryRuntimeChecks() async {
|
||
let (runtime, _, recorder) = makeRuntime()
|
||
do {
|
||
try await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
} catch {
|
||
check("search accessory runtime boots", false, error.localizedDescription)
|
||
return
|
||
}
|
||
|
||
let command = """
|
||
"use strict";
|
||
const { List } = require("@raycast/api");
|
||
const React = require("react");
|
||
const h = React.createElement;
|
||
module.exports.default = function Command() {
|
||
const [type, setType] = React.useState(null);
|
||
const accessory = h(List.Dropdown, {
|
||
defaultValue: "all",
|
||
onChange: setType,
|
||
},
|
||
h(List.Dropdown.Item, { title: "All Types", value: "all" }),
|
||
h(List.Dropdown.Item, { title: "Folders", value: "folders" })
|
||
);
|
||
return h(List, { searchBarAccessory: accessory },
|
||
type ? h(List.Item, { title: "filter=" + type }) : null
|
||
);
|
||
};
|
||
"""
|
||
await runtime.start(
|
||
session: "sAccessory", code: command,
|
||
file: URL(fileURLWithPath: "/tmp/search-accessory.js"), mode: .view,
|
||
context: launchContext())
|
||
await settle()
|
||
|
||
guard let firstTree = recorder.trees.last else {
|
||
check("dropdown-filtered command renders", false)
|
||
runtime.shutdown()
|
||
return
|
||
}
|
||
let firstScreen = ExtensionScreen(tree: firstTree, query: "")
|
||
check("dropdown-filtered command starts empty", firstScreen.items.isEmpty)
|
||
guard
|
||
let accessory = ExtensionSearchAccessory(
|
||
node: firstTree.activeRoot?.node("searchBarAccessory")),
|
||
let initialValue = accessory.initialValue(stored: nil),
|
||
let handler = accessory.onChange
|
||
else {
|
||
check("live dropdown exposes an initial dispatch", false)
|
||
runtime.shutdown()
|
||
return
|
||
}
|
||
check("live dropdown exposes an initial dispatch", initialValue == "all")
|
||
check("an uncontrolled dropdown leaves the value to Swift", accessory.controlledValue == nil)
|
||
|
||
await runtime.dispatch(
|
||
session: "sAccessory", handler: handler,
|
||
payload: ExtensionRuntime.jsonString(from: [initialValue]))
|
||
await settle()
|
||
let seededScreen = recorder.trees.last.map { ExtensionScreen(tree: $0, query: "") }
|
||
check(
|
||
"initial dropdown dispatch reveals filtered rows",
|
||
seededScreen?.items.first?.node.string("title") == "filter=all",
|
||
seededScreen?.items.first?.node.string("title") ?? "no row")
|
||
|
||
let renderCount = recorder.trees.count
|
||
await settle(50)
|
||
check(
|
||
"a seeded dropdown settles rather than re-rendering",
|
||
recorder.trees.count == renderCount,
|
||
"\(renderCount) → \(recorder.trees.count)")
|
||
|
||
// The node id is what the held pick is keyed by, so a re-render must not renumber it.
|
||
check(
|
||
"the dropdown keeps its node id across renders",
|
||
recorder.trees.last.flatMap {
|
||
ExtensionSearchAccessory(node: $0.activeRoot?.node("searchBarAccessory"))?.nodeID
|
||
} == accessory.nodeID)
|
||
await runtime.stop(session: "sAccessory")
|
||
}
|
||
|
||
@MainActor
|
||
static func nodeContractChecks() async {
|
||
let directory = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("tinycast-archive-\(UUID().uuidString)")
|
||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let (runtime, host, recorder) = makeRuntime()
|
||
try? await runtime.boot(config: .current(supportDirectory: directory))
|
||
let command = """
|
||
const fs = require("fs"), zlib = require("zlib"), assert = require("assert");
|
||
const call = (name, ...args) => new Promise((resolve, reject) =>
|
||
fs[name](...args, (error, ...values) => error ? reject(error) : resolve(values)));
|
||
module.exports.default = async () => {
|
||
const file = "\(directory.path)/output", moved = file + ".moved";
|
||
const gzip = Buffer.from("H4sIAAAAAAAC/ytJLGL4X5BYmZOfmAIANNN0xgwAAAA=", "base64");
|
||
const expected = Buffer.from("74617200ff7061796c6f6164", "hex");
|
||
const unzip = new zlib.Unzip();
|
||
const concat = Buffer.concat;
|
||
let decoded;
|
||
try {
|
||
Buffer.concat = (chunks) => chunks;
|
||
assert.equal(unzip._processChunk(gzip.subarray(0, 10), 0).length, 0);
|
||
decoded = unzip._processChunk(gzip.subarray(10), 4);
|
||
} finally { Buffer.concat = concat; unzip.close(); }
|
||
assert(decoded.equals(expected));
|
||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
|
||
const [fd] = await call("open", file, flags, 0o600);
|
||
const [written, same] = await call("write", fd, decoded, 0, decoded.length, null);
|
||
assert.equal(written, expected.length); assert(same === decoded);
|
||
await call("futimes", fd, new Date(1000000), new Date(2000000));
|
||
await call("close", fd);
|
||
const code = (fn) => { try { fn(); return "none"; } catch (error) { return error.code; } };
|
||
assert.equal(code(() => fs.openSync(file, "wx")), "EEXIST");
|
||
const [reader] = await call("open", file, "r");
|
||
fs.renameSync(file, moved);
|
||
const buffer = Buffer.alloc(expected.length + 2, 42);
|
||
const [count, sameBuffer] = await call("read", reader, buffer, 1, expected.length, 0);
|
||
assert.equal(count, expected.length); assert(sameBuffer === buffer);
|
||
assert(buffer.subarray(1, -1).equals(expected)); assert.equal(buffer[0], 42);
|
||
assert.equal(fs.readSync(reader, buffer, 0, 3, null), 3);
|
||
assert.equal(buffer.subarray(0, 3).toString(), "tar");
|
||
assert.equal(fs.readSync(reader, buffer, 0, expected.length, null), expected.length - 3);
|
||
assert.equal(fs.readSync(reader, buffer, 0, 1, null), 0);
|
||
fs.closeSync(reader);
|
||
assert.equal(code(() => fs.readSync(reader, buffer, 0, 1, null)), "EBADF");
|
||
assert.equal(code(() => fs.openSync(moved + "/nested", "w")), "ENOTDIR");
|
||
const writer = fs.openSync(moved, "r+");
|
||
fs.writeSync(writer, Buffer.from("X"), 0, 1, 2);
|
||
fs.writeSync(writer, Buffer.from("Y"), 0, 1, null);
|
||
fs.closeSync(writer);
|
||
assert.equal(fs.readFileSync(moved).subarray(0, 3).toString(), "YaX");
|
||
const listing = "\(directory.path)/listing";
|
||
fs.mkdirSync(listing + "/folder", { recursive: true });
|
||
fs.writeFileSync(listing + "/entry", "");
|
||
const handle = fs.opendirSync(listing);
|
||
assert.equal(handle.path, listing);
|
||
const first = handle.readSync(), second = handle.readSync();
|
||
assert.equal(handle.readSync(), null);
|
||
handle.closeSync();
|
||
assert.equal(code(() => handle.readSync()), "ERR_DIR_CLOSED");
|
||
const byName = Object.fromEntries([first, second].map((entry) => [entry.name, entry]));
|
||
assert(byName.entry.isFile() && byName.folder.isDirectory());
|
||
assert.equal(byName.entry.parentPath, listing);
|
||
const [callbackDir] = await call("opendir", listing);
|
||
assert.equal((await callbackDir.read()).constructor, fs.Dirent);
|
||
await callbackDir.close();
|
||
const iterated = await Array.fromAsync(await fs.promises.opendir(listing));
|
||
assert.equal(iterated.map((entry) => entry.name).sort().join(), "entry,folder");
|
||
const zlibDecoded = new zlib.Unzip()._processChunk(
|
||
Buffer.from("eJwrSSxi+F+QWJmTn5gCACHpBTE=", "base64"), 4);
|
||
assert(zlibDecoded.equals(expected));
|
||
const invalid = new zlib.Unzip();
|
||
assert.throws(() => invalid._processChunk(Buffer.from("invalid"), 4));
|
||
invalid.close();
|
||
// axios picks its Node http adapter by this tag, and inherits from streams ES5-style.
|
||
assert.equal(Object.prototype.toString.call(process), "[object process]");
|
||
const { Readable, Writable } = require("stream");
|
||
// node-fetch sends a body through `Readable.from`, which never splits it into bytes.
|
||
const body = await Array.fromAsync(Readable.from("hello"));
|
||
assert.equal(body.length, 1); assert.equal(String(body[0]), "hello");
|
||
function Legacy() { Writable.call(this, { highWaterMark: 7 }); }
|
||
Legacy.prototype = Object.create(Writable.prototype);
|
||
Legacy.prototype._write = function (chunk, encoding, callback) { this.seen = chunk; callback(); };
|
||
const legacy = new Legacy();
|
||
assert(legacy instanceof Writable);
|
||
assert.equal(legacy.writableLength, 0);
|
||
legacy.write(Buffer.from("hi"));
|
||
assert.equal(String(legacy.seen), "hi");
|
||
await require("@raycast/api").showHUD("archive IO passed");
|
||
};
|
||
"""
|
||
await runtime.start(
|
||
session: "archive", code: command, file: directory.appendingPathComponent("test.js"),
|
||
mode: .noView, context: launchContext(mode: .noView))
|
||
await settle()
|
||
check(
|
||
"node file, zlib and stream contracts", host.huds == ["archive IO passed"],
|
||
recorder.failures.joined(separator: "|"))
|
||
await runtime.stop(session: "archive")
|
||
runtime.shutdown()
|
||
}
|
||
|
||
/// `withAccessToken` hands React an async component, which only renders while the promise it
|
||
/// suspended on comes back rather than being remade every attempt (#519).
|
||
@MainActor
|
||
static func asyncComponentChecks() async {
|
||
let (runtime, _, recorder) = makeRuntime()
|
||
do {
|
||
try await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
} catch {
|
||
check("async component runtime boots", false, error.localizedDescription)
|
||
return
|
||
}
|
||
|
||
let command = """
|
||
"use strict";
|
||
const { List, ActionPanel, Action } = require("@raycast/api");
|
||
const React = require("react");
|
||
const h = React.createElement;
|
||
function Inner() {
|
||
const [count, setCount] = React.useState(0);
|
||
return h(List, null, h(List.Item, {
|
||
title: "count=" + count,
|
||
actions: h(ActionPanel, null,
|
||
h(Action, { title: "Bump", onAction: () => setCount((v) => v + 1) })),
|
||
}));
|
||
}
|
||
async function Wrapped(props) { return await Inner(props); }
|
||
module.exports.default = function Command(props) { return h(Wrapped, props); };
|
||
"""
|
||
await runtime.start(
|
||
session: "sAsync", code: command, file: URL(fileURLWithPath: "/tmp/async.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle()
|
||
|
||
check(
|
||
"an async command renders", recorder.failures.isEmpty,
|
||
recorder.failures.joined(separator: "\n"))
|
||
guard let tree = recorder.trees.last else {
|
||
check("an async command reaches the screen", false)
|
||
runtime.shutdown()
|
||
return
|
||
}
|
||
var screen = ExtensionScreen(tree: tree, query: "")
|
||
check(
|
||
"an async command reaches the screen",
|
||
screen.items.first?.node.string("title") == "count=0",
|
||
screen.items.first?.node.string("title") ?? "nil")
|
||
|
||
let actions = ExtensionScreen.actions(in: screen.actionPanel(forItemAt: 0))
|
||
if let handler = actions.first?.handler {
|
||
await runtime.dispatch(
|
||
session: "sAsync", handler: handler,
|
||
payload: ExtensionRuntime.jsonString(from: []))
|
||
await settle()
|
||
screen = ExtensionScreen(tree: recorder.trees.last!, query: "")
|
||
check(
|
||
"state inside an async command still updates",
|
||
screen.items.first?.node.string("title") == "count=1",
|
||
screen.items.first?.node.string("title") ?? "nil")
|
||
} else {
|
||
check("state inside an async command still updates", false, "no dispatchable action")
|
||
}
|
||
await runtime.stop(session: "sAsync")
|
||
}
|
||
|
||
/// Raycast's `swift:` wrapper chmods its bundled helper before spawning it: store zips ship it 644.
|
||
@MainActor
|
||
static func swiftHelperChecks() async {
|
||
let helper = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("tinycast-helper-\(UUID().uuidString)")
|
||
try? Data("#!/bin/sh\necho '{\"hex\":\"#FF0000\"}'\n".utf8).write(to: helper)
|
||
defer { try? FileManager.default.removeItem(at: helper) }
|
||
|
||
let (runtime, _, recorder) = makeRuntime()
|
||
try? await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
let command = """
|
||
"use strict";
|
||
const { Detail } = require("@raycast/api");
|
||
const React = require("react");
|
||
const { chmod } = require("fs/promises");
|
||
const { spawn } = require("child_process");
|
||
module.exports.default = function Command() {
|
||
const [state, setState] = React.useState("pending");
|
||
React.useEffect(() => {
|
||
(async () => {
|
||
await chmod("\(helper.path)", "755");
|
||
const child = spawn("\(helper.path)", ["pick"]);
|
||
const out = [];
|
||
child.stdout.on("data", (chunk) => out.push(chunk.toString()));
|
||
child.on("exit", (code) => setState(code + ":" + JSON.parse(out.join("")).hex));
|
||
})().catch((error) => setState("threw:" + error.message));
|
||
}, []);
|
||
return React.createElement(Detail, { markdown: state });
|
||
};
|
||
"""
|
||
await runtime.start(
|
||
session: "sSwift", code: command, file: URL(fileURLWithPath: "/tmp/swift-helper.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle(1200)
|
||
|
||
let mode = (try? FileManager.default.attributesOfItem(atPath: helper.path))
|
||
.flatMap { $0[.posixPermissions] as? NSNumber }
|
||
check("chmod applies the requested mode", mode?.intValue == 0o755, String(describing: mode))
|
||
check(
|
||
"a chmodded helper is spawnable",
|
||
recorder.trees.last?.activeRoot?.string("markdown") == "0:#FF0000",
|
||
recorder.trees.last?.activeRoot?.string("markdown") ?? "no tree")
|
||
await runtime.stop(session: "sSwift")
|
||
}
|
||
|
||
/// Timers pauses by storing `exec`'s pid and later `process.kill`ing the shell before it rings.
|
||
@MainActor
|
||
static func processKillChecks() async {
|
||
let marker = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("tinycast-rang-\(UUID().uuidString)")
|
||
defer { try? FileManager.default.removeItem(at: marker) }
|
||
|
||
let (runtime, _, recorder) = makeRuntime()
|
||
try? await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
let command = """
|
||
"use strict";
|
||
const { Detail } = require("@raycast/api");
|
||
const React = require("react");
|
||
const { exec } = require("child_process");
|
||
const process = require("process");
|
||
const code = (run) => { try { run(); return "ok"; } catch (error) { return error.code; } };
|
||
module.exports.default = function Command() {
|
||
const [state, setState] = React.useState("pending");
|
||
React.useEffect(() => {
|
||
const child = exec("sleep 1 >/dev/null 2>&1; touch '\(marker.path)'", (error) => {
|
||
setState([live, error ? "failed" : "passed", child.kill(), code(() => process.kill(0)),
|
||
code(() => process.kill(2147483647, 0)), code(() => process.kill(child.pid, "SIGNOPE"))
|
||
].join(","));
|
||
});
|
||
const live = child.pid > 0 && process.kill(child.pid, 0) && process.kill(child.pid);
|
||
}, []);
|
||
return React.createElement(Detail, { markdown: state });
|
||
};
|
||
"""
|
||
await runtime.start(
|
||
session: "sKill", code: command, file: URL(fileURLWithPath: "/tmp/process-kill.js"),
|
||
mode: .view, context: launchContext())
|
||
await settle(1500)
|
||
|
||
check(
|
||
"a killed exec child never runs the rest of its script",
|
||
!FileManager.default.fileExists(atPath: marker.path))
|
||
check(
|
||
"exec returns a live pid and process.kill guards Tinycast itself",
|
||
recorder.trees.last?.activeRoot?.string("markdown")
|
||
== "true,failed,false,EPERM,ESRCH,ERR_UNKNOWN_SIGNAL",
|
||
recorder.trees.last?.activeRoot?.string("markdown") ?? "no tree")
|
||
await runtime.stop(session: "sKill")
|
||
}
|
||
|
||
/// `zlib` is the one node shim with no JS-side implementation to lean on.
|
||
static func zlibChecks() {
|
||
let payload = Data(String(repeating: "tinycast extensions ", count: 64).utf8)
|
||
do {
|
||
check("gzip round-trips", try Zlib.gunzip(Zlib.gzip(payload)) == payload)
|
||
check("zlib round-trips", try Zlib.inflate(Zlib.deflate(payload)) == payload)
|
||
check("raw deflate round-trips", try Zlib.inflateRaw(Zlib.deflateRaw(payload)) == payload)
|
||
check("gzip actually compresses", try Zlib.gzip(payload).count < payload.count)
|
||
} catch {
|
||
check("zlib round-trips", false, error.localizedDescription)
|
||
}
|
||
// Known-answer checks so a framing bug can't hide behind a self-consistent round-trip.
|
||
check("crc32", Zlib.crc32(Data("123456789".utf8)) == 0xCBF4_3926)
|
||
check("adler32", Zlib.adler32(Data("123456789".utf8)) == 0x091E_01DE)
|
||
}
|
||
|
||
// MARK: - Running a real extension
|
||
|
||
@MainActor
|
||
static func runInstalledExtension(directory: URL, command commandName: String?) async {
|
||
guard let manifest = try? ExtensionManifest.load(directory: directory) else {
|
||
print("Not an extension: \(directory.path)")
|
||
exit(1)
|
||
}
|
||
let runnable = manifest.commands.filter { $0.mode.isSupported }
|
||
guard
|
||
let target = commandName.flatMap({ name in runnable.first { $0.name == name } })
|
||
?? runnable.first
|
||
else {
|
||
print("No runnable command in \(manifest.title)")
|
||
exit(1)
|
||
}
|
||
let bundle = directory.appendingPathComponent("\(target.name).js")
|
||
guard let code = try? String(contentsOf: bundle, encoding: .utf8) else {
|
||
print("Missing built bundle: \(bundle.path)")
|
||
exit(1)
|
||
}
|
||
|
||
print("▶ \(manifest.title) — \(target.title) (\(target.mode.rawValue))")
|
||
let (runtime, host, recorder) = makeRuntime()
|
||
do {
|
||
try await runtime.boot(config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
} catch {
|
||
print("boot failed: \(error.localizedDescription)")
|
||
exit(1)
|
||
}
|
||
|
||
var preferences: [String: ExtensionPreferenceValue] = [:]
|
||
for schema in manifest.preferences + target.preferences {
|
||
preferences[schema.name] = schema.effectiveDefault
|
||
}
|
||
// `EXT_TEST_PREFS` stands in for Settings: many extensions have no manifest default.
|
||
for (key, value) in environmentPreferences() { preferences[key] = value }
|
||
let context = launchContext(
|
||
extensionName: manifest.name, command: target.name, mode: target.mode,
|
||
assets: directory.appendingPathComponent("assets").path, preferences: preferences,
|
||
arguments: target.completeArguments(environmentArguments()))
|
||
let settleMS = UInt64(
|
||
ProcessInfo.processInfo.environment["EXT_TEST_SETTLE_MS"].flatMap(UInt64.init) ?? 1500)
|
||
|
||
// `EXT_TEST_RERUN=1` runs, tears down and runs again: the works-once-then-hangs case.
|
||
if ProcessInfo.processInfo.environment["EXT_TEST_RERUN"] != nil {
|
||
await runtime.start(
|
||
session: "r1", code: code, file: bundle, mode: target.mode, context: context)
|
||
await settle(settleMS)
|
||
print("run 1: \(recorder.trees.count) render(s), \(recorder.failures.count) failure(s)")
|
||
await runtime.stop(session: "r1")
|
||
runtime.shutdown()
|
||
let first = recorder.trees.count
|
||
try? await runtime.boot(
|
||
config: .current(supportDirectory: FileManager.default.temporaryDirectory))
|
||
print("→ tore down after \(first) render(s); re-running in a fresh context")
|
||
}
|
||
|
||
await runtime.start(
|
||
session: "s1", code: code, file: bundle, mode: target.mode, context: context)
|
||
await settle(settleMS)
|
||
|
||
for failure in recorder.failures { print("✗ \(failure)") }
|
||
if ProcessInfo.processInfo.environment["EXT_TEST_VERBOSE"] != nil {
|
||
for line in recorder.logs { print(" \(line)") }
|
||
}
|
||
print(
|
||
"\(recorder.trees.count) render(s); host calls: \(Set(host.calls).sorted().joined(separator: ", "))"
|
||
)
|
||
for toast in host.toasts { print(" toast: \(toast)") }
|
||
for hud in host.huds { print(" hud: \(hud)") }
|
||
if let tree = recorder.trees.last {
|
||
let screen = ExtensionScreen(tree: tree, query: "")
|
||
print("root: \(screen.kind) rows: \(screen.rows.count) fields: \(screen.fields.count)")
|
||
for item in screen.items.prefix(12) {
|
||
let node = item.node
|
||
let accessories = node.array("accessories").count
|
||
print(
|
||
" • \(node.string("title") ?? "")"
|
||
+ (node.string("subtitle").map { " — \($0)" } ?? "")
|
||
+ (accessories > 0 ? " [\(accessories) accessories]" : "")
|
||
+ (node.node("actions") != nil ? " ⌘K" : ""))
|
||
}
|
||
if case .detail = screen.kind {
|
||
print(" markdown: \((screen.root?.string("markdown") ?? "").prefix(200))")
|
||
}
|
||
}
|
||
await runtime.stop(session: "s1")
|
||
exit(recorder.failures.isEmpty ? 0 : 1)
|
||
}
|
||
}
|
||
|
||
/// The accessory label rule lives in the view layer; mirror just the string case for the harness.
|
||
private func ExtensionAccessoriesView_labelForTest(_ value: RenderValue?) -> String? {
|
||
guard let value else { return nil }
|
||
if let text = value.stringValue { return text }
|
||
return value.objectValue?["text"]?.stringValue
|
||
}
|