Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions clients/dotnet/Mobium/Device.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,9 +1462,10 @@ public void Dispose()

/// <summary>
/// Ends the session on the device and closes the connection. The teardown is the daemon's own: accessibility
/// settings put back, a recording or route stopped, WebViews detached,
/// settings put back, a route stopped, WebViews detached,
/// the device-side server stopped, and the app Start launched, if any,
/// stopped too. Quitting a session that is not open succeeds, and a
/// stopped too. A running recording or audio capture is discarded, with
/// a line on standard error. Quitting a session that is not open succeeds, and a
/// second quit — a <c>using</c> block ending after an explicit one —
/// does nothing. A program that exits without quitting or disposing
/// has the sessions it started ended for it: mobium sees the client go.
Expand All @@ -1477,7 +1478,12 @@ public void Quit()
if (Session != null) args["device"] = Session.Device;
try
{
_conn.Call("app_session", args);
var ended = Data("app_session", args);
if (ended.TryGetValue("discarded", out var discarded) && discarded is IList<object?> names)
{
foreach (var what in Strings(names))
Console.Error.WriteLine($"mobium: {what} was discarded, unsaved: stop it before Quit() to keep it");
}
}
finally
{
Expand Down
1 change: 1 addition & 0 deletions clients/dotnet/Tests/FakeMobium.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ internal static int Run()
{
view["device"] = (string?)args?["device"] ?? "";
view["ended"] = true;
if (mode == "discards") view["discarded"] = new JsonArray("the screen recording");
}
Reply(stdout, id, new JsonObject
{
Expand Down
22 changes: 22 additions & 0 deletions clients/dotnet/Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ private static int Main(string[] args)
DisposeIsIdempotentAndEndsAWaitingCall();
AnAnswerWithNoIdFailsTheCallInFlight();
StartOpensTheSessionAndQuitEndsIt();
QuitWarnsAboutWhatItDiscarded();
UploadSendsOnlyWhatWasGiven();
DownloadSavesDecodesOrLists();
MapDiffSendsDiffAndReadsWhatChanged();
Expand Down Expand Up @@ -611,6 +612,27 @@ private static void AnAnswerWithNoIdFailsTheCallInFlight()
Eq("and the connection is still in step", "app_current", Json.Str(d.Call("app_current", null), "tool"));
}

private static void QuitWarnsAboutWhatItDiscarded()
{
var stderr = Console.Error;
var written = new StringWriter();
Console.SetError(written);
try
{
Environment.SetEnvironmentVariable("MOBIUM_FAKE", "discards");
Device.Builder().Binary(FakeBinary()).Platform("android").Start().Quit();
Yes("quit warns about the recording", written.ToString().Contains("the screen recording was discarded"));
written.GetStringBuilder().Clear();
Environment.SetEnvironmentVariable("MOBIUM_FAKE", "ok");
Device.Builder().Binary(FakeBinary()).Platform("android").Start().Quit();
Yes("quit warns about nothing when nothing was discarded", written.ToString().Length == 0);
}
finally
{
Console.SetError(stderr);
}
}

private static void StartOpensTheSessionAndQuitEndsIt()
{
Environment.SetEnvironmentVariable("MOBIUM_FAKE", "ok");
Expand Down
40 changes: 39 additions & 1 deletion clients/go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package mobium

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -146,7 +148,11 @@ func fakeTool(name string, args map[string]any, scenario string) map[string]any
return text("session started", map[string]any{"action": "start", "device": "fake-device",
"platform": platform, "driver": driver, "app": app, "sessions": []any{}})
case "end":
return text("session ended", map[string]any{"action": "end", "device": args["device"], "ended": true, "sessions": []any{}})
view := map[string]any{"action": "end", "device": args["device"], "ended": true, "sessions": []any{}}
if os.Getenv(mobiumFakeEnv) == "discards" {
view["discarded"] = []any{"the screen recording"}
}
return text("session ended", view)
default:
return text("no session is open", map[string]any{"action": "status", "sessions": []any{}})
}
Expand Down Expand Up @@ -644,6 +650,38 @@ func TestStartOpensTheSessionAndQuitEndsIt(t *testing.T) {
}
}

func TestQuitSaysWhatItDiscarded(t *testing.T) {
ctx := context.Background()
var logged bytes.Buffer
log.SetOutput(&logged)
defer log.SetOutput(os.Stderr)

t.Setenv(mobiumFakeEnv, "discards")
dev, err := Start(ctx, WithBinary(os.Args[0]))
if err != nil {
t.Fatalf("start: %v", err)
}
if err := dev.Quit(ctx); err != nil {
t.Fatalf("quit: %v", err)
}
if !strings.Contains(logged.String(), "the screen recording was discarded") {
t.Errorf("logged %q", logged.String())
}

logged.Reset()
t.Setenv(mobiumFakeEnv, "1")
dev, err = Start(ctx, WithBinary(os.Args[0]))
if err != nil {
t.Fatalf("start: %v", err)
}
if err := dev.Quit(ctx); err != nil {
t.Fatalf("quit: %v", err)
}
if logged.Len() != 0 {
t.Errorf("logged %q with nothing discarded", logged.String())
}
}

func TestADeviceFromConnectHasNoSession(t *testing.T) {
dev := connectFake(t, "")
if dev.Session() != nil {
Expand Down
12 changes: 11 additions & 1 deletion clients/go/mobium.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
Expand Down Expand Up @@ -216,6 +217,9 @@ func (d *Device) Session() *Session { return d.started }
// too. Quitting a session that is not open succeeds, and a second Quit — a
// deferred one after an explicit one, say — does nothing.
//
// A running recording or audio capture is discarded, and said so through the
// standard logger.
//
// A program that exits without Quit or Close has the sessions it started
// ended for it, the same way: mobium sees the client go.
func (d *Device) Quit(ctx context.Context) error {
Expand All @@ -227,7 +231,13 @@ func (d *Device) Quit(ctx context.Context) error {
if d.started != nil {
args["device"] = d.started.Device
}
err := d.act(ctx, "app_session", args)
var ended struct {
Discarded []string `json:"discarded"`
}
err := d.data(ctx, "app_session", args, &ended)
for _, what := range ended.Discarded {
log.Printf("mobium: %s was discarded, unsaved: stop it before Quit to keep it", what)
}
if cerr := d.Close(); err == nil {
err = cerr
}
Expand Down
13 changes: 10 additions & 3 deletions clients/java/src/main/java/dev/mobium/Mobium.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

/**
* Drives mobile apps on Android emulators, Android phones, iOS simulators
Expand Down Expand Up @@ -39,6 +40,8 @@
*/
public final class Mobium implements AutoCloseable {

private static final Logger LOG = Logger.getLogger("dev.mobium");

private final Connection conn;
// What start() opened, so quit() ends that device's session even when
// several are running. Null after connect().
Expand Down Expand Up @@ -2258,8 +2261,10 @@ public Map<String, Object> call(String tool, Map<String, Object> arguments) {
* server stopped, and the app {@code start()} launched, if any, stopped
* too. Quitting a session that is not open succeeds, and a second quit --
* say, try-with-resources closing after an explicit one -- does nothing.
* A program that exits without quitting or closing has the sessions it
* started ended for it: mobium sees the client go.
* A running recording or audio capture is discarded, with a warning on
* the {@code dev.mobium} logger. A program that exits without quitting or
* closing has the sessions it started ended for it: mobium sees the client
* go.
*/
public void quit() {
if (quit) return;
Expand All @@ -2268,7 +2273,9 @@ public void quit() {
args.put("action", "end");
if (session != null) args.put("device", session.device());
try {
conn.call("app_session", args);
for (Object what : Json.asArray(data("app_session", args).get("discarded"))) {
LOG.warning(what + " was discarded, unsaved: stop it before quit() to keep it");
}
} finally {
conn.close();
}
Expand Down
1 change: 1 addition & 0 deletions clients/java/src/test/java/dev/mobium/FakeMobium.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ public static void main(String[] args) throws Exception {
} else if (action.equals("end")) {
view.put("device", Json.str(a, "device"));
view.put("ended", true);
if (mode.equals("discards")) view.put("discarded", List.of("the screen recording"));
}
view.put("sessions", List.of());
reply(out, id, Map.of("content", List.of(Map.of("type", "text", "text", "session")), "structuredContent", view));
Expand Down
24 changes: 24 additions & 0 deletions clients/java/src/test/java/dev/mobium/Tests.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static void main(String[] args) {
closeIsIdempotentAndEndsAWaitingCall();
anAnswerWithNoIdFailsTheCallInFlight();
startOpensTheSessionAndQuitEndsIt();
quitWarnsAboutWhatItDiscarded();
filesSendTheirArgumentsAndDecodeTheAnswer();
mapDiffSendsDiffAndReturnsTheDiff();

Expand Down Expand Up @@ -475,6 +476,29 @@ static void anAnswerWithNoIdFailsTheCallInFlight() {
}
}

static void quitWarnsAboutWhatItDiscarded() {
List<String> warned = new ArrayList<>();
java.util.logging.Logger log = java.util.logging.Logger.getLogger("dev.mobium");
java.util.logging.Handler handler = new java.util.logging.Handler() {
@Override public void publish(java.util.logging.LogRecord r) { warned.add(r.getMessage()); }
@Override public void flush() { }
@Override public void close() { }
};
log.addHandler(handler);
log.setUseParentHandlers(false);
try {
Mobium.builder().binary(FakeProcess.launcher("discards", null)).platform("android").start().quit();
yes("quit warns about the recording",
warned.size() == 1 && warned.get(0).contains("the screen recording was discarded"));
warned.clear();
Mobium.builder().binary(FakeProcess.launcher("ok", null)).platform("android").start().quit();
yes("quit warns about nothing when nothing was discarded", warned.isEmpty());
} finally {
log.removeHandler(handler);
log.setUseParentHandlers(true);
}
}

static void startOpensTheSessionAndQuitEndsIt() {
String fake = FakeProcess.launcher("ok", null);
Mobium d = Mobium.builder().binary(fake).platform("ios").app("com.apple.Preferences").start();
Expand Down
8 changes: 6 additions & 2 deletions clients/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1828,7 +1828,8 @@ export class Device {
/**
* Ends the session on the device and closes the connection. The teardown is the daemon's own: accessibility settings put
* back, a recording or route stopped, WebViews detached, the device-side
* server stopped, and the app start() launched, if any, stopped too.
* server stopped, and the app start() launched, if any, stopped too. A
* running recording or audio capture is discarded, with a MobiumWarning.
* Quitting a session that is not open succeeds, and a second quit does
* nothing. A script that exits without quit() or close() has the sessions
* it started ended for it: mobium sees the client go.
Expand All @@ -1840,7 +1841,10 @@ export class Device {
const args = { action: 'end' }
if (this.session) args.device = this.session.device
try {
await this.conn.callTool('app_session', args)
const ended = (await this.conn.callTool('app_session', args)).structuredContent || {}
for (const what of ended.discarded || []) {
process.emitWarning(`${what} was discarded, unsaved: stop it before quit() to keep it`, 'MobiumWarning')
}
} finally {
await this.close()
}
Expand Down
18 changes: 18 additions & 0 deletions clients/javascript/test/connection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ const within = (p, ms) => Promise.race([settle(p), new Promise((r) => setTimeout
check(after.ok === false && /closed/.test(after.e.message), `a call after close does not say it was closed: ${JSON.stringify(after)}`)
}

// -- quit warns about what it discarded -------------------------------------
{
const warned = []
const listen = (w) => { if (w.name === 'MobiumWarning') warned.push(w.message) }
process.on('warning', listen)
process.env.MOBIUM_FAKE = 'discards'
await (await m.start({ platform: 'android', binary: FAKE })).quit()
await new Promise((resolve) => setImmediate(resolve))
check(warned.length === 1 && warned[0].includes('the screen recording was discarded'),
`quit did not warn about the recording: ${JSON.stringify(warned)}`)
warned.length = 0
process.env.MOBIUM_FAKE = 'ok'
await (await m.start({ platform: 'android', binary: FAKE })).quit()
await new Promise((resolve) => setImmediate(resolve))
check(warned.length === 0, `quit warned with nothing discarded: ${JSON.stringify(warned)}`)
process.off('warning', listen)
}

// -- start opens the session, quit ends it --------------------------------
{
process.env.MOBIUM_FAKE = 'ok'
Expand Down
1 change: 1 addition & 0 deletions clients/javascript/test/fake-mobium.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async function handle(msg) {
view = { action: 'start', device: 'fake-device', platform, driver: platform === 'ios' ? 'wda' : 'uiautomator2', app: args.app || '', sessions: [] }
} else if (args.action === 'end') {
view = { action: 'end', device: args.device || '', ended: true, sessions: [] }
if (mode === 'discards') view.discarded = ['the screen recording']
} else {
view = { action: 'status', sessions: [] }
}
Expand Down
2 changes: 1 addition & 1 deletion clients/python/mobium/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
device.tap("@e1")
"""

from ._device import Bounds, Device, DeviceInfo, Element, Session, connect, start
from ._device import Bounds, Device, DeviceInfo, Element, MobiumWarning, Session, connect, start
from ._errors import (
AmbiguousLocatorError,
DeviceNotReadyError,
Expand Down
25 changes: 21 additions & 4 deletions clients/python/mobium/_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
from __future__ import annotations

import base64
import warnings
from dataclasses import dataclass
from typing import Any

from ._rpc import Connection, MobiumError, find_binary, _data_of, _text_of


class MobiumWarning(UserWarning):
"""Something was lost that nothing raised for: a recording quit() discarded."""


# Python shows a warning once per place by default, and every recording a
# process lost comes from the same place. Appended, so -W and pytest still win.
warnings.filterwarnings("always", category=MobiumWarning, append=True)


@dataclass(frozen=True)
class Bounds:
"""An on-screen rectangle in device pixels, on both platforms."""
Expand Down Expand Up @@ -185,21 +195,28 @@ def quit(self) -> None:
"""End the session on the device and close the connection.

The teardown is the daemon's own: accessibility settings put back, a
recording or route stopped, WebViews detached, the device-side server
stopped, and the app start() launched, if any, stopped too. Quitting a
route stopped, WebViews detached, the device-side server stopped, and
the app start() launched, if any, stopped too. A running recording or
audio capture is discarded, with a UserWarning. Quitting a
session that is not open succeeds, and a second quit -- say, a with
block ending after an explicit one -- does nothing. A script that exits
without quit() or close() has the sessions it started ended for it:
mobium sees the client go.
"""
self._end(stacklevel=3)

def _end(self, stacklevel: int) -> None:
if self._quit:
return
self._quit = True
args: dict[str, Any] = {"action": "end"}
if self.session is not None:
args["device"] = self.session.device
try:
self._call("app_session", args)
ended = self._data("app_session", args) or {}
for what in ended.get("discarded", []):
warnings.warn(f"{what} was discarded, unsaved: stop it before quit() to keep it",
MobiumWarning, stacklevel=stacklevel)
finally:
self.close()

Expand All @@ -215,7 +232,7 @@ def __exit__(self, *_: Any) -> None:
# block; one from connect() only closes, leaving the session to
# whoever opened it.
if self.session is not None:
self.quit()
self._end(stacklevel=3)
else:
self.close()

Expand Down
2 changes: 2 additions & 0 deletions clients/python/tests/fake_mobium.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ def send(obj):
"driver": "wda" if platform == "ios" else "uiautomator2", "app": args.get("app", ""), "sessions": []}
elif action == "end":
view = {"action": "end", "device": args.get("device", ""), "ended": True, "sessions": []}
if os.environ.get("MOBIUM_FAKE") == "discards":
view["discarded"] = ["the screen recording"]
else:
view = {"action": "status", "sessions": []}
send({"jsonrpc": "2.0", "id": i, "result": {"content": [{"type": "text", "text": action or "status"}],
Expand Down
Loading
Loading