From 3816e9c4bce037c6daf7130bdd7ac054bf57ef68 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 18:40:19 +0100 Subject: [PATCH] feat(ios): show the app's forwarded stdout and stderr Debug builds ask the runtime to forward stdout and stderr through forwardStdio in the app's package.json. They arrive under the org.nativescript.stdio subsystem and show from --log debug, like the rest of the app's native output and as Xcode's console does, labeled with their stream. --- docs/man_pages/project/testing/run-ios.md | 2 +- lib/common/definitions/mobile.d.ts | 2 ++ lib/common/mobile/ios/ios-log-entry.ts | 12 ++++++-- .../simulator/ios-simulator-log-provider.ts | 2 ++ .../test/unit-tests/mobile/ios-log-entry.ts | 30 +++++++++++++++++++ lib/controllers/prepare-controller.ts | 1 + test/controllers/prepare-controller.ts | 2 ++ 7 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 43259fe40f..c808c8f0e0 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -33,7 +33,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--emulator` - If set, runs the app in all available and configured ios simulators. It will start a simulator if none are already running. * `--sdk` - Specifies the target simulator's sdk. * `--justlaunch` - If set, does not print the application output in the console. -* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. +* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code, `print()` output and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 32a4bb7f4c..12e8de36a6 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -173,6 +173,8 @@ declare global { image: string; level: string; message: string; + subsystem?: string; + category?: string; } interface IiOSLogStreamOptions { diff --git a/lib/common/mobile/ios/ios-log-entry.ts b/lib/common/mobile/ios/ios-log-entry.ts index 0d1121fe52..7c1ff130e8 100644 --- a/lib/common/mobile/ios/ios-log-entry.ts +++ b/lib/common/mobile/ios/ios-log-entry.ts @@ -38,6 +38,10 @@ export function isIOSRuntimeLogEntry(entry: Mobile.IiOSLogEntry): boolean { return path.basename(entry.image || "") === "NativeScript"; } +// The runtime forwards the app's stdout and stderr under this subsystem, when +// the CLI asks for it through the forwardStdio key of the app's package.json. +const STDIO_SUBSYSTEM = "org.nativescript.stdio"; + // Logged by the runtime itself on every launch, not by the app. const RUNTIME_NOISE = ["NativeScript loaded bundle"]; @@ -57,6 +61,10 @@ export function getIOSLogEntryText( return null; } + if (entry.subsystem === STDIO_SUBSYSTEM) { + return options.includeNative ? `${entry.category}: ${entry.message}` : null; + } + const imageName = path.basename(entry.image || ""); if (isIOSRuntimeLogEntry(entry)) { return RUNTIME_NOISE.some((noise) => entry.message.includes(noise)) @@ -78,7 +86,7 @@ export function getIOSLogEntryText( // asks for it, through the logChunkMarkers key of the app's package.json. const CHUNK_JOIN_MARKER = "\u2060"; const CHUNK_LINE_MARKER = "\u2061"; -const CONSOLE_PREFIX = /^CONSOLE [A-Z]+: /; +const REPEATED_PREFIX = /^(?:CONSOLE [A-Z]+|stdout|stderr): /; // Long enough to ride out a backlog of chunks, while the rest of a message that // never arrives, because the app crashed, still gets printed. @@ -109,7 +117,7 @@ export class IOSLogChunkStitcher { const message = this.pending.get(key) ?? { text: "", - prefix: chunk.match(CONSOLE_PREFIX)?.[0] ?? "", + prefix: chunk.match(REPEATED_PREFIX)?.[0] ?? "", separator: "", timer: null, }; diff --git a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts index e560adf789..cb1e1e48f0 100644 --- a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts +++ b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts @@ -143,6 +143,8 @@ export class IOSSimulatorLogProvider image: event.senderImagePath, level: (event.messageType || "").toLowerCase(), message: event.eventMessage, + subsystem: event.subsystem, + category: event.category, }, this.$devicePlatformsConstants.iOS, deviceId, diff --git a/lib/common/test/unit-tests/mobile/ios-log-entry.ts b/lib/common/test/unit-tests/mobile/ios-log-entry.ts index e8c8cc4a2e..febf7271e0 100644 --- a/lib/common/test/unit-tests/mobile/ios-log-entry.ts +++ b/lib/common/test/unit-tests/mobile/ios-log-entry.ts @@ -98,6 +98,31 @@ describe("getIOSLogEntryText", () => { }); }); +describe("getIOSLogEntryText for forwarded stdio", () => { + const entry = { + process: "/var/containers/Bundle/Application/X/app.app/app", + image: "/private/Frameworks/NativeScript.framework/NativeScript", + level: "default", + message: "printed", + subsystem: "org.nativescript.stdio", + category: "stdout", + }; + + it("is hidden by default, unlike the runtime's console output", () => { + assert.isNull(getIOSLogEntryText(entry, { processName: "app" })); + }); + + it("is labeled with its stream along with native output", () => { + assert.equal( + getIOSLogEntryText( + { ...entry, category: "stderr" }, + { processName: "app", includeNative: true }, + ), + "stderr: printed", + ); + }); +}); + describe("getIOSLogScope", () => { it("maps the CLI log level to the app logs that are shown", () => { assert.deepEqual(getIOSLogScope("INFO", false), { @@ -159,6 +184,11 @@ describe("IOSLogChunkStitcher", () => { }); it("restores the line break a chunk was cut at", () => { + add(`stdout: one${JOIN}`); + add("stdout: two"); + assert.deepEqual(messages, ["stdout: onetwo"]); + messages.length = 0; + add(`CONSOLE ERROR: first${LINE}`); add("CONSOLE ERROR: second"); assert.deepEqual(messages, ["CONSOLE ERROR: first\nsecond"]); diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index f70795f49b..dcee53d1f4 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -526,6 +526,7 @@ export class PrepareController } if (prepareData && !prepareData.release) { packageData.logChunkMarkers = true; + packageData.forwardStdio = true; } if (packageData.ios && packageData.ios.discardUncaughtJsExceptions) { packageData.discardUncaughtJsExceptions = diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index 336db5e766..19ed116e4b 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -181,6 +181,8 @@ describe("prepareController", () => { it("asks for log chunk markers in iOS debug builds only", async () => { assert.isTrue((await writePackageJson("ios", false)).logChunkMarkers); assert.isUndefined((await writePackageJson("ios", true)).logChunkMarkers); + assert.isTrue((await writePackageJson("ios", false)).forwardStdio); + assert.isUndefined((await writePackageJson("ios", true)).forwardStdio); assert.isUndefined( (await writePackageJson("android", false)).logChunkMarkers, );