mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 13:56:06 +00:00
* fix(adb): report a null installer declaration as an absent installer
Package Manager prints its String fields by concatenation, so a package
without a recorded installer shows `installerPackageName=null` in
`dumpsys package`. The projection treated only an empty value as absent
and forwarded the text "null" as a present installer identity, which a
consumer cannot tell apart from a package literally named "null" (#3943).
`versionName` is printed the same way and had the same gap.
The parser now maps the empty value and the `null` sentinel of both
String fields to the nullable result's null at the producing boundary;
a reported installer such as com.android.vending is kept unchanged.
Validation: parser unit tests for the sentinel, empty and missing
installer lines and the absent version name, plus a provider-level
regression through the inspect_adb_package operation; three of them fail
on main and pass here. The issue's four CLI controls through
`rea inspect-adb-package` against a stub adb return JSON null for the
sentinel, empty and missing cases and keep com.android.vending. Typecheck,
lint and module boundaries pass.
Closes #3943
* fix(adb): keep the null sentinel handling to the installer field
Review feedback on #4073: a manifest can declare the literal string
"null" as android:versionName, and dumpsys prints it exactly like an
absent value, so mapping versionName to null would turn a real version
into absence. Only installerPackageName, where no package can carry that
name, treats the printed null as the absent installer. The test for the
version name now pins the retained text.
* test(adb): establish permission inventory in null-installer fixture
Main now reports partial coverage when the requested-permission
inventory is not established (9534e42ec). Give the null-installer
fixture a requested permissions section so it still checks a
complete projection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
parseAmStartOutput,
|
|
parseDumpsysPackageOutput,
|
|
parseWmDensityOutput,
|
|
parseWindowFocusOutput,
|
|
} from "./AdbShellOutput.js";
|
|
|
|
const packageDump = (installerLine: string | undefined): string =>
|
|
[
|
|
"Packages:",
|
|
" Package [com.example] (abcdef):",
|
|
" versionName=1.2.3",
|
|
...(installerLine === undefined ? [] : [` ${installerLine}`]),
|
|
" pkgFlags=[ HAS_CODE ]",
|
|
"",
|
|
].join("\n");
|
|
|
|
describe("parseDumpsysPackageOutput", () => {
|
|
it("keeps a reported installer package name", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput(
|
|
packageDump("installerPackageName=com.android.vending"),
|
|
).installerPackageName,
|
|
).toBe("com.android.vending");
|
|
});
|
|
|
|
// Package Manager prints an absent String field as the text "null"; a
|
|
// missing or empty declaration means the same absence (morluto/rea#3943).
|
|
it.each([
|
|
["the literal null sentinel", "installerPackageName=null"],
|
|
["an empty value", "installerPackageName="],
|
|
["a missing line", undefined],
|
|
])("reports an absent installer as null for %s", (_case, line) => {
|
|
expect(parseDumpsysPackageOutput(packageDump(line))).toMatchObject({
|
|
versionName: "1.2.3",
|
|
installerPackageName: null,
|
|
});
|
|
});
|
|
|
|
// A manifest may declare the literal string "null" as its version name and
|
|
// dumpsys prints it the same way as an absent one, so the text is kept.
|
|
it("keeps a version name printed as the text null", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput(
|
|
packageDump("installerPackageName=null").replace(
|
|
"versionName=1.2.3",
|
|
"versionName=null",
|
|
),
|
|
).versionName,
|
|
).toBe("null");
|
|
});
|
|
});
|
|
|
|
describe("parseWindowFocusOutput display contexts", () => {
|
|
it.each([false, true])(
|
|
"retains both display owners independent of block order: %s",
|
|
(reverse) => {
|
|
const inactive =
|
|
" Display: mDisplayId=3 rootTasks=0\n mCurrentFocus=null\n mFocusedApp=null\n";
|
|
const active =
|
|
" Display: mDisplayId=0 rootTasks=12\n mCurrentFocus=Window{active}\n mFocusedApp=ActivityRecord{active}\n";
|
|
const result = parseWindowFocusOutput(
|
|
reverse ? active + inactive : inactive + active,
|
|
);
|
|
expect(result).toMatchObject({
|
|
focused_window: null,
|
|
focused_app: null,
|
|
focus_summary: "ambiguous",
|
|
focus_contexts: expect.arrayContaining([
|
|
{
|
|
scope: "display",
|
|
display_id: "3",
|
|
header_line: " Display: mDisplayId=3 rootTasks=0",
|
|
declarations: [
|
|
{
|
|
field: "mCurrentFocus",
|
|
value: null,
|
|
raw_line: " mCurrentFocus=null",
|
|
},
|
|
{
|
|
field: "mFocusedApp",
|
|
value: null,
|
|
raw_line: " mFocusedApp=null",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
scope: "display",
|
|
display_id: "0",
|
|
header_line: " Display: mDisplayId=0 rootTasks=12",
|
|
declarations: [
|
|
{
|
|
field: "mCurrentFocus",
|
|
value: "Window{active}",
|
|
raw_line: " mCurrentFocus=Window{active}",
|
|
},
|
|
{
|
|
field: "mFocusedApp",
|
|
value: "ActivityRecord{active}",
|
|
raw_line: " mFocusedApp=ActivityRecord{active}",
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("does not pair declarations from different displays", () => {
|
|
expect(
|
|
parseWindowFocusOutput(
|
|
"Display: mDisplayId=0\nmCurrentFocus=Window{one}\nDisplay: mDisplayId=1\nmFocusedApp=ActivityRecord{two}\n",
|
|
),
|
|
).toMatchObject({
|
|
focus_summary: "ambiguous",
|
|
focused_window: null,
|
|
focused_app: null,
|
|
focus_contexts: [
|
|
{
|
|
display_id: "0",
|
|
declarations: [{ field: "mCurrentFocus", value: "Window{one}" }],
|
|
},
|
|
{
|
|
display_id: "1",
|
|
declarations: [
|
|
{ field: "mFocusedApp", value: "ActivityRecord{two}" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("retains an unknown display identity and missing fields without borrowing its neighbor", () => {
|
|
expect(
|
|
parseWindowFocusOutput(
|
|
"Display: vendor-format\nmCurrentFocus=null\nDisplay: mDisplayId=0\n",
|
|
),
|
|
).toMatchObject({
|
|
focus_summary: "ambiguous",
|
|
focus_contexts: [
|
|
{
|
|
scope: "display",
|
|
display_id: null,
|
|
header_line: "Display: vendor-format",
|
|
declarations: [{ field: "mCurrentFocus", value: null }],
|
|
},
|
|
{ scope: "display", display_id: "0", declarations: [] },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseWindowFocusOutput declaration presence", () => {
|
|
it("separates missing declarations from explicit absence and retains source lines", () => {
|
|
expect(parseWindowFocusOutput("no declarations\n")).toEqual({
|
|
focused_window: null,
|
|
focused_app: null,
|
|
focus_summary: "unavailable",
|
|
focus_contexts: [],
|
|
observed_lines: [],
|
|
});
|
|
expect(
|
|
parseWindowFocusOutput(" mCurrentFocus=null\nmFocusedApp=null\n"),
|
|
).toEqual({
|
|
focused_window: null,
|
|
focused_app: null,
|
|
focus_summary: "single_context",
|
|
focus_contexts: [
|
|
{
|
|
scope: "unscoped",
|
|
display_id: null,
|
|
header_line: null,
|
|
declarations: [
|
|
{
|
|
field: "mCurrentFocus",
|
|
value: null,
|
|
raw_line: " mCurrentFocus=null",
|
|
},
|
|
{ field: "mFocusedApp", value: null, raw_line: "mFocusedApp=null" },
|
|
],
|
|
},
|
|
],
|
|
observed_lines: [" mCurrentFocus=null", "mFocusedApp=null"],
|
|
});
|
|
});
|
|
|
|
it("retains repeated declarations without choosing one or erasing explicit absence", () => {
|
|
expect(
|
|
parseWindowFocusOutput(
|
|
"mCurrentFocus=null\nmCurrentFocus=Window{later}\nmFocusedApp=ActivityRecord{current}\n",
|
|
),
|
|
).toMatchObject({
|
|
focus_summary: "ambiguous",
|
|
focused_window: null,
|
|
focused_app: null,
|
|
focus_contexts: [
|
|
{
|
|
declarations: [
|
|
{ field: "mCurrentFocus", value: null },
|
|
{ field: "mCurrentFocus", value: "Window{later}" },
|
|
{ field: "mFocusedApp", value: "ActivityRecord{current}" },
|
|
],
|
|
},
|
|
],
|
|
observed_lines: [
|
|
"mCurrentFocus=null",
|
|
"mCurrentFocus=Window{later}",
|
|
"mFocusedApp=ActivityRecord{current}",
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseDumpsysPackageOutput", () => {
|
|
it("reports unknown requested permissions when the package is null", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n")
|
|
.requestedPermissions,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("reports unknown when package state and the permission section are absent", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput(
|
|
"Package [com.example] (abcdef):\n versionCode=45\n",
|
|
).requestedPermissions,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("reports zero only for a confirmed loaded package without the section", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput(
|
|
"Package [com.example] (abcdef):\n pkg=Package{abcdef com.example}\n",
|
|
).requestedPermissions,
|
|
).toBe(0);
|
|
});
|
|
|
|
it("counts listed permissions when the section is available", () => {
|
|
expect(
|
|
parseDumpsysPackageOutput(
|
|
"Package [com.example] (abcdef):\n requested permissions:\n android.permission.INTERNET\n",
|
|
).requestedPermissions,
|
|
).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("parseWmDensityOutput", () => {
|
|
it("reads labeled physical and override densities", () => {
|
|
expect(
|
|
parseWmDensityOutput("Physical density: 420\nOverride density: 480\n"),
|
|
).toEqual({ physical: 420, override: 480 });
|
|
expect(parseWmDensityOutput("Physical density: 420\n")).toEqual({
|
|
physical: 420,
|
|
override: null,
|
|
});
|
|
});
|
|
|
|
// Older builds print the physical density as a bare number
|
|
// (morluto/rea#3942); it must not be lost as an unread declaration.
|
|
it("reads a bare numeric density as the physical density", () => {
|
|
expect(parseWmDensityOutput("420\n")).toEqual({
|
|
physical: 420,
|
|
override: null,
|
|
});
|
|
expect(parseWmDensityOutput("420\r\n")).toEqual({
|
|
physical: 420,
|
|
override: null,
|
|
});
|
|
});
|
|
|
|
it("prefers a labeled physical density over a bare number", () => {
|
|
expect(parseWmDensityOutput("560\nPhysical density: 420\n")).toEqual({
|
|
physical: 420,
|
|
override: null,
|
|
});
|
|
});
|
|
|
|
it("reports no density for unrecognized output", () => {
|
|
expect(parseWmDensityOutput("")).toEqual({
|
|
physical: null,
|
|
override: null,
|
|
});
|
|
expect(parseWmDensityOutput("420dpi\n")).toEqual({
|
|
physical: null,
|
|
override: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
const starting =
|
|
"Starting: Intent { act=android.intent.action.MAIN cmp=com.example/.Main }";
|
|
|
|
describe("parseAmStartOutput", () => {
|
|
it("recognizes a CRLF wait-timeout report", () => {
|
|
expect(
|
|
parseAmStartOutput(
|
|
`${starting}\r\nStatus: timeout\r\nActivity: com.example/.Main\r\nComplete\r\n`,
|
|
),
|
|
).toMatchObject({
|
|
status: "refused",
|
|
activity: "com.example/.Main",
|
|
reportedStatus: "timeout",
|
|
reason: "am start reported status: timeout",
|
|
});
|
|
});
|
|
|
|
it("treats every non-ok status as refusal, including a later status after ok", () => {
|
|
expect(
|
|
parseAmStartOutput(`${starting}\nStatus: ok\nStatus: timeout\n`),
|
|
).toMatchObject({
|
|
status: "refused",
|
|
reportedStatus: "timeout",
|
|
reason: "am start reported status: timeout",
|
|
});
|
|
expect(
|
|
parseAmStartOutput(`${starting}\nStatus: future-value\n`),
|
|
).toMatchObject({
|
|
status: "refused",
|
|
reportedStatus: "future-value",
|
|
});
|
|
});
|
|
|
|
it("gives hard errors in either stream priority over status and attempt", () => {
|
|
expect(
|
|
parseAmStartOutput(
|
|
`${starting}\nStatus: timeout\nActivity: com.example/.Main\n`,
|
|
"Exception: Activity class does not exist\n",
|
|
),
|
|
).toEqual({
|
|
status: "refused",
|
|
startedActivity: "act=android.intent.action.MAIN cmp=com.example/.Main",
|
|
activity: "com.example/.Main",
|
|
reportedStatus: "timeout",
|
|
reason: "Activity class does not exist",
|
|
});
|
|
expect(
|
|
parseAmStartOutput(
|
|
`${starting}\nStatus: ok\nError: Activity class does not exist\n`,
|
|
),
|
|
).toMatchObject({
|
|
status: "refused",
|
|
reason: "Activity class does not exist",
|
|
});
|
|
});
|
|
|
|
it("keeps Starting-only output compatible and refuses no-attempt output", () => {
|
|
expect(parseAmStartOutput(starting)).toMatchObject({
|
|
status: "started",
|
|
reportedStatus: null,
|
|
activity: null,
|
|
});
|
|
expect(
|
|
parseAmStartOutput("Status: ok\nWarning: Activity not started\n"),
|
|
).toMatchObject({
|
|
status: "refused",
|
|
startedActivity: null,
|
|
reportedStatus: "ok",
|
|
});
|
|
});
|
|
});
|