diff --git a/appium/tests/helpers/app.ts b/appium/tests/helpers/app.ts index 60bb404..f997a5f 100644 --- a/appium/tests/helpers/app.ts +++ b/appium/tests/helpers/app.ts @@ -322,6 +322,46 @@ export async function returnToApp() { await driver.pause(1_000); } +export async function hasLogView(): Promise { + return $(byTestIdSelector('log_view')) + .isExisting() + .catch(() => false); +} + +/** Text of every entry in the demo's on-screen log view, oldest first. */ +export async function getLogMessages(): Promise { + if (isWebViewSDK) { + return driver.execute(() => + Array.from(document.querySelectorAll('[data-testid^="log_entry_"]')).map( + (el) => el.textContent ?? '', + ), + ); + } + + const selector = + getPlatform() === 'android' + ? 'android=new UiSelector().resourceIdMatches(".*log_entry_.*")' + : '-ios predicate string:name BEGINSWITH "log_entry_"'; + return $$(selector).map((el) => el.getText()); +} + +/** Number of log entries that contain every one of `parts`. */ +export async function countLogMessages(...parts: string[]): Promise { + const messages = await getLogMessages(); + return messages.filter((message) => parts.every((part) => message.includes(part))).length; +} + +/** Expect exactly one new log entry containing every one of `parts` beyond `baseline`. */ +export async function expectOneNewLog(parts: string[], baseline = 0, timeoutMs = 15_000) { + await driver.waitUntil(async () => (await countLogMessages(...parts)) > baseline, { + timeout: timeoutMs, + timeoutMsg: `No new log entry containing ${JSON.stringify(parts)}`, + }); + // Give a duplicate replay time to land before counting. + await driver.pause(2_000); + expect(await countLogMessages(...parts)).toBe(baseline + 1); +} + /** * Expand an Android notification row when OEMs keep it collapsed. The chevron * lookup is scoped to the ancestor row containing `title`; otherwise a busy @@ -394,7 +434,9 @@ export async function waitForNotification(opts: { await image.waitForDisplayed({ timeout: 5_000 }); } - await returnToApp(); + // Tap rather than press back so the click listener fires, matching iOS. + await titleEl.click(); + await driver.pause(1_000); } finally { await ensureMainWebViewContext(); } @@ -460,12 +502,24 @@ export async function checkNotification(opts: { const button = await scrollToEl(opts.buttonId); await driver.pause(2_000); // small wait to hopefully get image notif early + const displayLog = ['Notification foregroundWillDisplay:', opts.title]; + const clickLog = ['Notification click:', opts.title]; + const logged = await hasLogView(); + const [displaysBefore, clicksBefore] = logged + ? await Promise.all([countLogMessages(...displayLog), countLogMessages(...clickLog)]) + : [0, 0]; + await button.click(); await waitForNotification({ title: opts.title, body: opts.body, expectImage: opts.expectImage, }); + + // Demos without the log view can't report JS events yet. + if (!logged) return; + await expectOneNewLog(displayLog, displaysBefore); + await expectOneNewLog(clickLog, clicksBefore); } export async function isWebViewVisible() { diff --git a/demo/build.md b/demo/build.md index 3e2166e..b3b3de2 100644 --- a/demo/build.md +++ b/demo/build.md @@ -123,6 +123,19 @@ OneSignal.LiveActivities.setupDefault({ - InAppMessages: willDisplay, didDisplay, willDismiss, didDismiss, click - Notifications: click, foregroundWillDisplay +Every listener in steps 2 and 3 writes one entry to the Log View (Prompt 2.0) and the console. Use these messages exactly; the shared Appium suite matches on them: + +| Event | Log message | +| ------------------------------- | ------------------------------------------------------------------------------------ | +| Notification click | `Notification click: {notificationId} {title}` | +| Notification foregroundWillDisplay | `Notification foregroundWillDisplay: {notificationId} {title}` | +| IAM willDisplay / didDisplay / willDismiss / didDismiss / click | `IAM {event}: {messageId}` (e.g. `IAM didDisplay: {messageId}`) | +| Push subscription change | `Push subscription changed: id={prev} → {curr}, optedIn={prev} → {curr}, token={prev8}… → {curr8}…` | +| Notification permission change | `Permission changed: {granted}` | +| User state change | `User changed: onesignalId={id}, externalId={externalId}` | + +Use `null` for missing ids and an empty string for a missing title. Truncate push tokens to their first 8 characters. + **3. Register state-layer observers.** Wire these in the state container so they can mutate UI state: - Push subscription change @@ -137,6 +150,7 @@ Clean up listeners on teardown if the platform requires it. ### Section Order (top to bottom) +0. **Log View** (sticky under the app bar, outside the scroll view; see Prompt 2.0) 1. **App Section** (App ID, Guidance Banner, Consent Toggle) 2. **User Section** (Status, External ID, Login/Logout) 3. **Push Section** (Push ID, Enabled Toggle, Auto-prompts on load) @@ -154,6 +168,20 @@ Clean up listeners on teardown if the platform requires it. 15. **Live Activities Section** (iOS only - Start, Update, End) 16. **Next Screen Button** +### Prompt 2.0 - Log View + +A logcat-style panel pinned directly below the app bar. It stays visible while the main scroll view scrolls underneath it. + +1. Header row: `LOGS ({count})` on the left; trash icon (clear) and chevron (collapse/expand) on the right. +2. Body: fixed-height list of entries, oldest first, each `{HH:mm:ss} {message}` with the time de-emphasized. Auto-scroll to the newest entry when one is added or the panel is expanded. Long entries wrap. +3. Empty state: `No logs yet`. +4. Collapse hides the body only; the header row stays. Starts expanded on every launch. Entries stay in the view hierarchy while collapsed. +5. Clear removes all entries. +6. Keep at most 100 entries in memory (drop the oldest). Never persist them: every launch, including a cold start from a notification tap, starts empty so the suite can assert per-launch event counts. +7. Expose a single `log(message)` helper that appends an entry and writes `[OneSignal] {message}` to the console. All SDK listeners (Prompt 1.5) go through it. + +Styling in styles.md (Log View). + ### Prompt 2.1a - App Section 1. App ID display (readonly text). @@ -405,6 +433,7 @@ This ensures SDK is configured before init, and UI reflects SDK's actual state. ### Not Persisted (In-Memory Only) +- **Log View entries**: session-only, cleared on restart - **triggersList**: session-only, cleared on restart - **aliasesList**: fetched from API on start/login, local adds are immediate (SDK syncs async) - **emailsList, smsNumbersList**: fetched from API each session @@ -449,6 +478,15 @@ Use the platform's native accessibility/test ID mechanism. The ids MUST match ex Section keys: `app`, `user`, `push`, `send_push`, `iam`, `send_iam`, `aliases`, `emails`, `sms`, `tags`, `outcomes`, `triggers`, `custom_events`, `location`, `live_activities` +**Log View**: + +| Identifier | Element | +| ------------------- | ----------------------------------------------------------- | +| `log_view` | Log View container | +| `log_entry_{index}` | Log entry text, 0-based, oldest first | +| `log_clear_button` | Trash (clear) button; accessibility label `Clear logs` | +| `log_toggle_button` | Collapse/expand chevron; label `Collapse logs` / `Expand logs` | + **Value displays**: | Identifier | Element | diff --git a/demo/styles.md b/demo/styles.md index a60e83f..d1244cf 100644 --- a/demo/styles.md +++ b/demo/styles.md @@ -17,6 +17,8 @@ Design spec for OneSignal SDK demo/sample apps. Values use platform-independent | osDivider | `#E8EAED` | Dividers | | osWarningBackground | `#FFF8E1` | Guidance / warning banner card | | osBackdrop | `rgba(0,0,0,0.54)` | Dialog / overlay backdrop | +| osLogBackground | `#1E1E1E` | Log View background | +| osLogText | `#D4D4D4` | Log View entry text | ## Spacing @@ -200,6 +202,25 @@ When a list exceeds `maxVisible` items (default 5), the overflow is hidden behin - Bottom padding: 24 +## Log View + +Sticky panel below the header bar (see build.md Prompt 2.0). Dark, logcat-style, independent of light/dark app theme. + +| Property | Value | +| ---------------- | ------------------------------------------------------------ | +| Background | osLogBackground (`#1E1E1E`) | +| Entry text | osLogText (`#D4D4D4`), monospace, 8 | +| Timestamp, empty state | osGrey600 | +| Header text | `LOGS ({count})`, osGrey500, monospace, 11, bold, 0.5 letter spacing | +| Header icons | osGrey500; trash (filled) 14, then chevron 18, 12 apart | +| Header height | 24 (fixed; content vertically centered) | +| Header padding | 0 vertical, 16 horizontal | +| Body height | 100 (fixed; scrolls internally) | +| Total height | 24 collapsed, 124 expanded | +| Body padding | 0 top, 16 horizontal, 6 bottom | +| Entry line height | 1.4 | +| Stacking | Above page content, below the header bar | + ## Loading State Inline placeholder shown inside a list card while remote data is being fetched (e.g. aliases, tags, emails, SMS after login or cold start). Occupies the same slot as the empty-state message, so the layout does not shift when data arrives.