Files
bare-operating-system/packages/bare-os-booter/test.bare-discord-env.js
T
snxraven d3aa66b0cc
Release rolling / release (push) Successful in 10m20s
Updates
2026-08-18 20:01:31 -04:00

412 lines
12 KiB
JavaScript

/**
* Host .env injection + guest-script contract for ctx.bare.discordJS bots.
*/
import test from 'brittle'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
normalizeDiscordToken,
parseDotEnvText,
applyDiscordHostEnvToShellEnv
} from './lib/services/bare-discord-js-loader.js'
import {
registerPackedDiscordJs,
takePackedDiscordJs
} from './lib/ctx/bare-os-ctx-discord-registry.js'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const examplePath = path.join(root, 'examples/discord-ping-pong/index.js')
const binSrcPath = path.join(
root,
'packages/bare-os-coreutils/src/discord-bot.js'
)
test('packed discord registry is what standalone app.bundle uses', (t) => {
const prev = takePackedDiscordJs()
t.is(registerPackedDiscordJs({}), null)
const fake = { Client: function Client() {} }
t.is(registerPackedDiscordJs(fake), fake)
t.is(takePackedDiscordJs(), fake)
registerPackedDiscordJs(prev)
})
test('normalizeDiscordToken strips BOM, quotes leftovers, zwsp', (t) => {
t.is(normalizeDiscordToken(' tok.en \n'), 'tok.en')
t.is(normalizeDiscordToken('\uFEFFabc'), 'abc')
t.is(normalizeDiscordToken('ab\u200Bc'), 'abc')
t.is(normalizeDiscordToken(null), '')
})
test('parseDotEnvText handles export, quotes, comments', (t) => {
const parsed = parseDotEnvText(
'# comment\nexport DISCORD_TOKEN="abc.def"\nDISCORD_GUILD_ID=99\n'
)
t.is(parsed.DISCORD_TOKEN, 'abc.def')
t.is(parsed.DISCORD_GUILD_ID, '99')
})
test('applyDiscordHostEnvToShellEnv reads host .env file path', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
const envPath = path.join(dir, '.env')
fs.writeFileSync(
envPath,
'DISCORD_TOKEN=host.file.token\nDISCORD_GUILD_ID=42\nDISCORD_ID_WHITELIST=111,222\n'
)
const shellEnv = {}
applyDiscordHostEnvToShellEnv(
{ DISCORD_ENV_FILE: envPath, BARE_OS_DISCORD: '1' },
shellEnv
)
t.is(shellEnv.DISCORD_TOKEN, 'host.file.token')
t.is(shellEnv.DISCORD_GUILD_ID, '42')
t.is(shellEnv.DISCORD_ID_WHITELIST, '111,222')
t.is(shellEnv.DISCORD_ENV_FILE, envPath)
fs.rmSync(dir, { recursive: true, force: true })
})
test('applyDiscordHostEnvToShellEnv copies DISCORD_ID_WHITELIST from host env', (t) => {
const shellEnv = {}
applyDiscordHostEnvToShellEnv(
{ DISCORD_ID_WHITELIST: ' 123 , 456 ', DISCORD_TOKEN: 't.ok' },
shellEnv
)
t.is(shellEnv.DISCORD_ID_WHITELIST, ' 123 , 456 ')
t.is(shellEnv.DISCORD_TOKEN, 't.ok')
})
test('applyDiscordHostEnvToShellEnv does not overwrite existing token', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
const envPath = path.join(dir, '.env')
fs.writeFileSync(envPath, 'DISCORD_TOKEN=from.file\n')
const shellEnv = { DISCORD_TOKEN: 'already.set' }
applyDiscordHostEnvToShellEnv({ DISCORD_ENV_FILE: envPath }, shellEnv)
t.is(shellEnv.DISCORD_TOKEN, 'already.set')
fs.rmSync(dir, { recursive: true, force: true })
})
test('guest --check resolves token from env and VFS .env path', async (t) => {
const src = fs.readFileSync(examplePath, 'utf8')
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const files = {
'/home/guest/.discord.env': 'DISCORD_TOKEN=vfs.file.token\n'
}
function makeCtx(env) {
const logs = []
const errors = []
return {
logs,
errors,
ctx: {
bare: { discordJS: { Client: function Client() {} } },
env,
console: {
log: (s) => logs.push(String(s)),
error: (s) => errors.push(String(s))
},
vfs: {
readFile: async (p) => {
const text = files[p]
if (text == null) return null
return Buffer.from(text)
}
},
b4a: { toString: (b) => Buffer.from(b).toString('utf8') },
exitCode: 0
}
}
}
const missing = makeCtx({})
const fn = new AsyncFunction(
'ctx',
'argv',
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
)
await fn(missing.ctx, ['discord-bot', '--check'])
t.is(missing.ctx.exitCode, 1)
const missingJson = JSON.parse(missing.logs.join('\n'))
t.is(missingJson.discordJS, true)
t.is(missingJson.tokenPresent, false)
const fromEnv = makeCtx({ DISCORD_TOKEN: 'session.token' })
await fn(fromEnv.ctx, ['discord-bot', '--check'])
t.is(fromEnv.ctx.exitCode, 0)
t.is(JSON.parse(fromEnv.logs.join('\n')).tokenSource, 'DISCORD_TOKEN')
const fromFile = makeCtx({})
await fn(fromFile.ctx, [
'discord-bot',
'--check',
'--env',
'/home/guest/.discord.env'
])
t.is(fromFile.ctx.exitCode, 0)
t.is(
JSON.parse(fromFile.logs.join('\n')).tokenSource,
'/home/guest/.discord.env'
)
})
test('example and /bin discord-bot are guest-safe (no import/require)', (t) => {
const example = fs.readFileSync(examplePath, 'utf8')
const binSrc = fs.readFileSync(binSrcPath, 'utf8')
for (const [label, src] of [
['example', example],
['bin', binSrc]
]) {
t.ok(/async function run\s*\(\s*ctx/.test(src), label + ' defines run(ctx)')
t.ok(
/ctx\.bare/.test(src) && /discordJS/.test(src),
label + ' uses ctx.bare.discordJS'
)
t.absent(
/^\s*import\s/m.test(src) || /^\s*export\s/m.test(src),
label + ' has no ESM import/export'
)
t.absent(
/\brequire\s*\(/.test(src),
label + ' has no require() (guest AsyncFunction)'
)
t.ok(/--env/.test(src), label + ' documents --env')
t.ok(
/--message-content/.test(src),
label + ' documents --message-content (privileged intent is opt-in)'
)
}
})
function makeLoginHangClient(emitAfterMs, eventName, payload) {
function Client(opts) {
this.opts = opts || {}
this.handlers = Object.create(null)
}
Client.prototype.on = function (ev, fn) {
if (!this.handlers[ev]) this.handlers[ev] = []
this.handlers[ev].push(fn)
return this
}
Client.prototype.once = function (ev, fn) {
return this.on(ev, fn)
}
Client.prototype.emit = function (ev, arg) {
const list = this.handlers[ev] || []
for (let i = 0; i < list.length; i++) list[i](arg)
}
Client.prototype.login = function () {
const self = this
setTimeout(function () {
self.emit(eventName, payload)
}, emitAfterMs)
return new Promise(function () {})
}
Client.prototype.destroy = async function () {}
return Client
}
const loginEvents = {
ClientReady: 'clientReady',
InteractionCreate: 'interactionCreate',
MessageCreate: 'messageCreate',
Error: 'error',
ShardError: 'shardError',
ShardDisconnect: 'shardDisconnect',
Debug: 'debug',
Warn: 'warn',
Invalidated: 'invalidated'
}
function loginBits() {
return {
Guilds: 1,
GuildMessages: 2,
DirectMessages: 4,
MessageContent: 8
}
}
function loginSlash() {
function SlashCommandBuilder() {}
SlashCommandBuilder.prototype.setName = function () {
return this
}
SlashCommandBuilder.prototype.setDescription = function () {
return this
}
SlashCommandBuilder.prototype.toJSON = function () {
return { name: 'ping' }
}
return SlashCommandBuilder
}
test('hanging login fails on gateway 4014 instead of waiting forever', async (t) => {
const src = fs.readFileSync(binSrcPath, 'utf8')
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const Client = makeLoginHangClient(5, 'shardDisconnect', { code: 4014 })
const errors = []
const logs = []
const ctx = {
bare: {
discordJS: {
Client,
GatewayIntentBits: loginBits(),
Events: loginEvents,
REST: function REST() {
this.setToken = function () {
return this
}
},
Routes: {},
SlashCommandBuilder: loginSlash()
}
},
env: { DISCORD_TOKEN: 'fake.token.value', DISCORD_LOGIN_TIMEOUT_MS: '2000' },
console: {
log: (s) => logs.push(String(s)),
error: (s) => errors.push(String(s))
},
exitCode: 0
}
const fn = new AsyncFunction(
'ctx',
'argv',
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
)
await fn(ctx, ['discord-bot'])
t.is(ctx.exitCode, 1)
t.ok(
errors.some((e) => /4014|disallowed intents/i.test(e)),
'reports gateway 4014: ' + errors.join(' | ')
)
t.ok(
logs.some((l) => /logging in/.test(l)),
'printed logging in'
)
})
test('standalone pack loads Discord WS bootstrap before discord.js', (t) => {
const packImports = fs.readFileSync(
path.join(root, 'packages/bare-os-booter/lib/ctx/bare-os-standalone-pack-imports.mjs'),
'utf8'
)
const standalone = fs.readFileSync(
path.join(root, 'packages/bare-os-booter/standalone.mjs'),
'utf8'
)
const bootIdx = packImports.indexOf('bare-os-discord-ws-bootstrap')
const discordIdx = packImports.indexOf('bare-os-ctx-discord-packed')
t.ok(bootIdx >= 0, 'pack-imports bootstraps Discord WS')
t.ok(discordIdx > bootIdx, 'WS bootstrap is imported before packed discord.js')
t.ok(
standalone.includes('bare-os-discord-ws-bootstrap'),
'standalone.mjs bootstraps Discord WS first'
)
t.ok(
/bare-os-discord-commands-guest\.cjs/.test(packImports),
'pack-imports includes the Discord command catalog as CJS'
)
const initd = fs.readFileSync(
path.join(root, 'packages/bare-os-booter/lib/services/bare-os-discord-initd.js'),
'utf8'
)
t.ok(
/import\s+discordCommandsNs\s+from\s+['"]\.\/bare-os-discord-commands-guest\.cjs['"]/.test(
initd
),
'initd statically imports the catalog (bare-pack rewrites this)'
)
t.absent(
/require\s*\(\s*['"]\.\/bare-os-discord-commands-guest/.test(initd),
'initd must not require() the catalog (MODULE_NOT_FOUND under app.bundle)'
)
})
test('WHATWG bare-ws wrapper exposes send (not Duplex-only Socket)', (t) => {
const src = fs.readFileSync(
path.join(
root,
'packages/bare-os-booter/vendor/bare-discord-js/src/adapters/whatwg-ws.cjs'
),
'utf8'
)
t.ok(/bare-os-discord-gateway-ws/.test(src), 'unique pack marker present')
t.ok(/class WhatwgWebSocket/.test(src), 'defines WhatwgWebSocket')
t.ok(/send\s*\(/.test(src), 'implements send()')
})
test('zlib-sync pack stub is not truthy (empty {} would hang login)', (t) => {
const stubPath = path.join(root, 'build/stubs/zlib-sync.cjs')
const stub = fs.readFileSync(stubPath, 'utf8')
t.ok(/module\.exports = null/.test(stub), 'stub exports null')
const wsMgr = fs.readFileSync(
path.join(root, 'node_modules/discord.js/src/client/websocket/WebSocketManager.js'),
'utf8'
)
t.ok(
/typeof zlib\.Inflate !== 'function'/.test(wsMgr),
'discord.js ignores zlib-sync without Inflate'
)
const wsJs = fs.readFileSync(
path.join(root, 'node_modules/@discordjs/ws/dist/index.js'),
'utf8'
)
t.ok(
/yielding before identify/.test(wsJs),
'@discordjs/ws defers identify after HELLO'
)
t.ok(
/Warmup gateway heartbeat before identify/.test(wsJs),
'@discordjs/ws sends a warmup heartbeat so IDENTIFY is not the first client frame'
)
})
test('default login does not request Message Content Intent', async (t) => {
const src = fs.readFileSync(binSrcPath, 'utf8')
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
let seenIntents = null
function Client(opts) {
seenIntents = opts && opts.intents
this.on = function () {
return this
}
this.once = function () {
return this
}
this.login = async function () {
throw Object.assign(new Error('stop after construct'), {
code: 'TokenInvalid'
})
}
this.destroy = async function () {}
}
const ctx = {
bare: {
discordJS: {
Client,
GatewayIntentBits: loginBits(),
Events: loginEvents,
REST: function REST() {},
Routes: {},
SlashCommandBuilder: loginSlash()
}
},
env: { DISCORD_TOKEN: 'fake.token.value' },
console: { log: () => {}, error: () => {} },
exitCode: 0
}
const fn = new AsyncFunction(
'ctx',
'argv',
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
)
await fn(ctx, ['discord-bot'])
t.ok(Array.isArray(seenIntents), 'Client received intents')
t.absent(
seenIntents.includes(8),
'MessageContent bit is not requested by default'
)
t.ok(seenIntents.includes(1), 'Guilds intent is requested')
t.ok(seenIntents.includes(4), 'DirectMessages intent is requested for DM agent chat')
})