///|
/// Automation of WeChat DevTools over the `miniprogram-automator` wire
/// protocol. This speaks the same WebSocket JSON as the official npm
/// package: requests are `{id, method, params}`, answers are
/// `{id, result}` or `{id, error: {message}}`; unsolicited events carry a
/// `method` but no `id`.
///
/// Calls are strictly one-in-flight: `send` writes a request, then drains
/// frames until the answer with the matching id shows up, dropping any
/// events along the way. That mirrors how this SDK uses the protocol
/// (probe, then one `wx.login`) and keeps the plumbing synchronous.
pub struct Automator {
ws : @websocket.Conn
mut seq : Int32
}
///|
/// The single error kind of the automation layer.
pub suberror Automation {
/// The DevTools cli could not start or the project window never opened.
Launch(String)
/// The WebSocket handshake or a request write failed.
Connect(String)
/// DevTools answered with a protocol error or an unusable payload.
Protocol(String)
/// The automation endpoint never came up, or a call never answered.
Timeout(String)
}
///|
pub impl Show for Automation with fn output(self : Automation, logger : &Logger) -> Unit {
match self {
Launch(detail) => logger.write_string("devtools launch: \{detail}")
Connect(detail) => logger.write_string("connect: \{detail}")
Protocol(detail) => logger.write_string("protocol: \{detail}")
Timeout(detail) => logger.write_string("timeout: \{detail}")
}
}
///|
/// `{}` — the params every request without extras carries.
fn empty_params() -> Json {
Json::empty_object()
}
///|
/// Connect to a DevTools automation endpoint (`ws://127.0.0.1:9420`).
pub async fn Automator::connect(endpoint : String) -> Automator {
let ws = @websocket.Conn::connect(endpoint)
{ ws, seq: 0, }
}
///|
/// Send one protocol request and await its answer. Frames that carry no
/// matching id (server-pushed events) are skipped.
pub async fn Automator::send(
self : Automator,
api~ : String,
params~ : Json = empty_params(),
) -> Json {
self.seq += 1
let id = "\{self.seq}"
let request : Json = { "id": id, "method": api, "params": params }
self.ws.send_text(request.stringify()) catch {
e => raise Connect("sending \{api} failed: \{e}")
}
// DevTools can take a while on a cold launch; be patient per call.
for ;; {
let frame = @async.with_timeout_opt(120_000, () => self.ws.recv())
let frame = match frame {
Some(frame) => frame
None =>
raise Timeout(
"\{api}: no answer after 120s — is the DevTools project window open?",
)
}
let text = @utf8.decode_lossy(frame.read_all().binary()[:])
let json = match @moon-pay-sdk.try_parse_json(text) {
Some(json) => json
None => continue
}
let fields = match json {
Object(fields) => fields
_ => continue
}
let frame_id = match fields.get("id") {
Some(String(frame_id)) => frame_id
_ => continue
}
if frame_id != id {
continue
}
match fields.get("error") {
Some(error) => {
let detail = match error {
String(text) => text
Object(_) =>
@moon-pay-sdk.string_field(error, "message") catch {
_ => @moon-pay-sdk.encode_json(error)
}
_ => @moon-pay-sdk.encode_json(error)
}
raise Protocol("\{api}: \{detail}")
}
None =>
return match fields.get("result") {
Some(result) => result
None => empty_params()
}
}
}
}
///|
/// `Tool.getInfo` — base info about the connected DevTools session; used
/// as a liveness probe before the real call.
pub async fn Automator::get_info(self : Automator) -> Json {
self.send(api="Tool.getInfo")
}
///|
/// `App.callWxMethod` — invoke one `wx.*` API inside the running
/// mini program and return the method's own result object.
pub async fn Automator::call_wx_method(self : Automator, api : String) -> Json {
let params : Json = { "method": api, "args": [] }
let answer = self.send(api="App.callWxMethod", params~)
match answer {
Object(fields) =>
match fields.get("result") {
Some(result) => result
None => answer
}
_ => answer
}
}
///|
/// Close the socket; DevTools and its project window stay open.
pub async fn Automator::close(self : Automator) -> Unit {
self.ws.send_close() catch {
_ => ()
}
self.ws.close()
}
///|
/// Read `res.code` out of a wx.login result.
pub fn login_code_from(result : Json) -> String raise {
@moon-pay-sdk.string_field(result, "code") catch {
_ =>
raise Protocol(
"wx.login returned no code: \{@moon-pay-sdk.encode_json(result)}",
)
}
}
///|
/// Launch DevTools itself: ` auto --project --auto-port
/// [--trust-project]` — the exact argv miniprogram-automator's launcher
/// sends. The cli output streams through with a `[devtools cli]` prefix;
/// the call returns once the automation port answers a WebSocket probe.
pub async fn launch_devtools(
cli_path~ : String,
project_path~ : String,
port~ : Int32,
trust_project~ : Bool = true,
) -> Unit {
let args = ["auto", "--project", project_path, "--auto-port", "\{port}"]
if trust_project {
args.push("--trust-project")
}
let (stdout_reader, child_stdout) = @process.read_from_process(shared=true)
let (stderr_reader, child_stderr) = @process.read_from_process(shared=true)
let _ = @async.with_task_group(group => {
group.spawn_bg(
() => pipe_to_prefix(stdout_reader),
no_wait=true,
allow_failure=true,
)
group.spawn_bg(
() => pipe_to_prefix(stderr_reader),
no_wait=true,
allow_failure=true,
)
// Fire and forget: the cli hands the request to the IDE and exits fast;
// the IDE then opens the project window and the automation port.
try
@process.spawn_orphan(
cli_path,
args[:],
stdout=child_stdout,
stderr=child_stderr,
)
catch {
e => raise Launch("failed to start \{cli_path}: \{e}")
} noraise {
_ => ()
}
0
})
println("launching: \{cli_path} \{args.join(" ")}")
let endpoint = "ws://127.0.0.1:\{port}"
for i in 0..<60 {
let probe = @async.with_timeout_opt(2_000, () => {
try {
let ws = @websocket.Conn::connect(endpoint)
ws.close()
true
} catch {
_ => false
} noraise {
ok => ok
}
})
match probe {
Some(true) => {
println("automation port \{port} is up")
return
}
_ => ()
}
if i % 4 == 3 {
println("waiting for DevTools automation port \{port} ... (\{i + 1})")
}
@async.sleep(500)
}
raise Timeout(
"DevTools automation port \{port} never opened within 30s — ensure the IDE is logged in, the service port is enabled in its security settings, and the project path is correct",
)
}
///|
/// Stream one child output pipe to the terminal, line by line. Started
/// detached, so it is cancelled with its task group — the child's pipes
/// close with the process either way.
async fn pipe_to_prefix(reader : @process.ReadFromProcess) -> Unit {
defer reader.close()
for ;; {
let line = reader.read_until("\n")
match line {
Some(line) =>
match line.trim().to_owned() {
"" => ()
text => println("[devtools cli] \{text}")
}
None => break
}
}
// Flush an unterminated tail line the line reader could not deliver.
let tail = reader.read_all()
let text = @utf8.decode_lossy(tail.binary()[:])
match text.trim().to_owned() {
"" => ()
text => println("[devtools cli] \{text}")
}
}
///|
/// One-shot automation: connect, probe, call `wx.login`, return
/// `res.code`. The whole flow is bounded by a 120s timeout.
pub async fn fetch_login_code(endpoint~ : String) -> String {
@async.with_timeout(
120_000,
() => {
let automator = Automator::connect(endpoint)
defer automator.close()
let _ = automator.get_info()
let result = automator.call_wx_method("login")
login_code_from(result)
},
error=Timeout("wx.login via DevTools timed out after 120s"),
)
}