Closer/qa/qa_push.js

101 lines
4.3 KiB
JavaScript
Raw Normal View History

// qa/qa_push.js — send a REAL notification+data FCM to a user's token (faithful to the app's own
// pushes) so a KILLED app shows it in the shade and tapping it cold-starts via the real OS splash
// handover. This is the ONE path that reproduces the "opens-and-closes" crash class (e.g. the
// splash-exit iconView NPE) — `adb am start --es …` does NOT (different launch path), and
// `am force-stop` can't even receive FCM. See ClaudeQAPlan.md (Pass E crash-triage / reproduction
// fidelity) and memory/project_notifications.md.
//
// Usage: NODE_PATH=functions/node_modules node qa/qa_push.js <uid> <type> [key=value ...]
// node qa/qa_push.js <uid> partner_started_game game_type=this_or_that
// node qa/qa_push.js <uid> partner_finished_game game_type=wheel # auto-resolves a completed session
// node qa/qa_push.js <uid> chat_message conversation_id=main
// node qa/qa_push.js <uid> partner_answered
// Env: SA_JSON, COUPLE_ID.
const admin = require('firebase-admin')
const path = require('path')
const os = require('os')
// Admin SA key lives OUTSIDE the repo (never keep live credentials in an agent-accessed
// working tree). Override with SA_JSON if yours is elsewhere.
const SA = process.env.SA_JSON ||
path.join(os.homedir(), '.config', 'closer', 'closer-admin-sa.json')
const COUPLE = process.env.COUPLE_ID
if (!COUPLE) {
console.error('COUPLE_ID is required. Keep fixture IDs in a local, gitignored env/file.')
process.exit(2)
}
admin.initializeApp({ credential: admin.credential.cert(require(SA)) })
const db = admin.firestore()
const [uid, type, ...rest] = process.argv.slice(2)
if (!uid || !type) {
console.error('usage: qa_push.js <uid> <type> [key=value ...]')
process.exit(2)
}
const extras = {}
for (const kv of rest) {
const i = kv.indexOf('=')
if (i > 0) extras[kv.slice(0, i)] = kv.slice(i + 1)
}
// Channel must exist in the app (NotificationChannelSetup). game_* → game_activity, else partner_activity.
const channelId = type.includes('game') ? 'game_activity' : 'partner_activity'
;(async () => {
// A results-ready push needs a completed session id; auto-resolve one if not supplied.
if (type === 'partner_finished_game' && !extras.game_session_id && extras.game_type) {
const q = await db.collection('couples').doc(COUPLE).collection(extras.game_type).get()
let pick = null
q.forEach((d) => {
const a = d.data().answers || {}
if (Object.keys(a).length >= 2 && !pick) pick = d.id
})
if (pick) extras.game_session_id = pick
}
const tk = await db.collection('users').doc(uid).collection('fcmTokens').get()
// Newest-first: a device that reinstalled registers a fresh token while stale ones linger
// (FCM rejects those with "Requested entity was not found"). The real Cloud Functions send to
// ALL tokens and prune the dead; here we just try newest-first until one delivers, so a stale
// leftover can't produce a false FAIL. (See sendToUser + pruneTokens in functions/.)
const ms = (v) => (v && v.toMillis ? v.toMillis() : typeof v === 'number' ? v : 0)
const tokens = tk.docs
.map((d) => ({ token: d.data().token, at: ms(d.data().updatedAt) }))
.filter((t) => t.token)
.sort((a, b) => b.at - a.at)
if (tokens.length === 0) {
console.error('NO_TOKEN for ' + uid + ' (launch the app once so it registers a token)')
process.exit(3)
}
const data = { type, couple_id: COUPLE, ...extras }
// Distinct body so the smoke can find + tap exactly this notification (not a grouped summary).
const marker = 'QA-SMOKE:' + type
const isDeadToken = (e) =>
e && (e.code === 'messaging/registration-token-not-registered' ||
e.code === 'messaging/invalid-registration-token' ||
/entity was not found|not.?registered/i.test(e.message || ''))
let lastErr
for (const { token } of tokens) {
try {
const res = await admin.messaging().send({
token,
notification: { title: 'Closer', body: marker },
android: { priority: 'high', notification: { channelId } },
data,
})
console.log('SENT ' + type + ' data=' + JSON.stringify(data) + ' -> ' + res)
process.exit(0)
} catch (e) {
lastErr = e
if (isDeadToken(e)) continue // stale token — try the next-newest
throw e
}
}
throw lastErr || new Error('all tokens failed')
})().catch((e) => {
console.error('ERR ' + e.message)
process.exit(1)
})