File size: 7,552 Bytes
ce0ef24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env node
'use strict';
// Execute the real inline dashboard script with a small DOM/fetch harness. No live API.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = process.argv[2] || __dirname;
const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
const ids = [...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]);
const OLD = {id: 'OLD_CHANNEL', title: 'Old channel'};
const NEW = {id: 'NEW_CHANNEL', title: 'New channel'};
const clone = o => JSON.parse(JSON.stringify(o));
let passed = 0;

function harness() {
  const elements = new Map(ids.map(id => [id, {
    hidden: false, disabled: false, value: '', textContent: '', innerHTML: '', focus() {}
  }]));
  const state = {active: clone(OLD), authorized: clone(NEW), automatic: false, events: [],
    requests: [], prompts: [], alerts: [], accept: true, blockCheck: null, failCheck: false};
  const storage = new Map();
  const sandbox = {
    console, TextEncoder, Date, JSON, String, Number, Math, Promise,
    btoa: s => Buffer.from(s, 'binary').toString('base64'),
    sessionStorage: {getItem: k => storage.get(k) || null, setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
    document: {hidden: false, activeElement: null,
      getElementById(id) {assert(elements.has(id), 'missing DOM element: ' + id);return elements.get(id)},
      addEventListener() {}},
    setInterval(fn) {state.timer = fn;return 1},
    alert(text) {state.alerts.push(text)},
    confirm(text) {state.prompts.push(text);return state.accept},
    async fetch(url, opts) {
      const body = opts.body ? JSON.parse(opts.body) : null;
      state.requests.push({url, method: opts.method, body, headers: opts.headers});
      let data, status = 200;
      if (url === '/api/youtube') data = {channel: clone(state.active), automatic: state.automatic,
        configured: true, events: clone(state.events), settings: {privacy:'public', title:'title', description:'description'}};
      else if (url === '/api/state') data = {tasks:[], logs:[], monitor_running:true, check_interval:20};
      else if (url === '/api/youtube/check') {
        if (state.blockCheck) await state.blockCheck;
        if (state.failCheck) {status=400;data={detail:'OAuth check failed'}}
        else data={channel:Object.assign(clone(state.authorized),
          state.active.id!==state.authorized.id ? {previous:clone(state.active)} : {})};
      } else if (url === '/api/youtube/start' || url === '/api/youtube/channel') {
        assert.equal(body.channel_id, state.authorized.id, 'must send the freshly checked channel id');
        state.active=clone(state.authorized);state.automatic=url.endsWith('/start');
        data={ok:true, channel:clone(state.active)};
      } else if (url === '/api/youtube/stop') {state.automatic=false;data={ok:true}}
      else throw Error('Unexpected route: '+url);
      return {ok:status<400,status,async json(){return clone(data)}};
    }
  };
  const context=vm.createContext(sandbox);
  vm.runInContext(script, context);
  vm.runInContext("authHeader='Basic dGVzdDp0ZXN0'", context);
  return {state, elements, context,
    async click(id){await vm.runInContext("$('"+id+"').onclick()",context)},
    async refresh(){await vm.runInContext('refresh()',context)},
    read(expr){return vm.runInContext(expr,context)}};
}
function pass(name){passed++;console.log('PASS  '+name)}

(async()=>{
  let h=harness();
  h.state.events=[{username:'muhajer_iraqi',title:'Live event',phase:'live',youtube_status:'live',
    source:'hd 720p',send_mode:'copy',pending_send_mode:'matchgop',output_seconds:60}];
  await h.refresh();
  assert.match(h.elements.get('events').innerHTML,/وضع الإرسال الفعلي: copy/);
  assert.match(h.elements.get('events').innerHTML,/الترميز الثابت ينتظر خانة: matchgop/);
  pass('Per-event sender mode is shown instead of hiding an account-specific fallback behind the global preference');
  await h.click('ytcheck');
  assert.equal(h.read('checkedChannel.id'),NEW.id);
  assert.equal(h.state.active.id,OLD.id);
  await h.refresh();await h.state.timer();
  assert.equal(h.read('checkedChannel.id'),NEW.id);
  assert.equal(h.read('lastY.channel.id'),OLD.id);
  assert.equal(h.elements.get('ytadopt').hidden,false);
  assert.match(h.elements.get('ytchannelnotice').textContent,/New channel/);
  pass('OAuth check → refresh → timer retains NEW, not the journal OLD (production regression)');

  await h.click('ytstart');
  const posts=h.state.requests.filter(r=>r.method==='POST');
  assert.equal(posts.at(-2).url,'/api/youtube/check');
  assert.equal(posts.at(-1).url,'/api/youtube/start');
  assert.equal(posts.at(-1).body.channel_id,NEW.id);
  assert.match(h.state.prompts.at(-1),/Old channel/);
  assert.match(h.state.prompts.at(-1),/New channel/);
  assert.equal(h.state.automatic,true);
  assert.equal(h.elements.get('ytadopt').hidden,true);
  pass('Start rechecks OAuth and explicitly confirms both channel names before NEW is enabled');

  h=harness();await h.click('ytcheck');await h.click('ytadopt');
  assert.equal(h.state.active.id,NEW.id);
  assert.equal(h.state.automatic,false);
  assert(!h.state.requests.some(r=>r.url==='/api/youtube/start'));
  assert.equal(h.elements.get('ytadopt').hidden,true);
  pass('Adopt-only confirms the new channel without starting a broadcast');

  h=harness();await h.click('ytcheck');h.state.accept=false;await h.click('ytstart');
  assert.equal(h.state.active.id,OLD.id);
  assert(!h.state.requests.some(r=>r.url==='/api/youtube/start'));
  pass('Cancelled start never sends a start request or adopts the channel');

  h=harness();await h.click('ytcheck');h.state.accept=false;await h.click('ytadopt');
  assert(!h.state.requests.some(r=>r.url==='/api/youtube/channel'));
  assert.equal(h.state.active.id,OLD.id);
  pass('Cancelled adoption leaves the previous channel unchanged');

  h=harness();await h.click('ytcheck');
  h.state.authorized={id:'THIRD_CHANNEL',title:'Rotated channel'};
  await h.click('ytstart');
  assert.equal(h.state.active.id,'THIRD_CHANNEL');
  assert.match(h.state.prompts.at(-1),/Rotated channel/);
  pass('Secrets rotated after check are freshly discovered, not silently confirmed with a stale id');

  h=harness();h.state.failCheck=true;await h.click('ytstart');
  assert(!h.state.requests.some(r=>r.url==='/api/youtube/start'));
  assert.match(h.elements.get('error').textContent,/OAuth check failed/);
  assert.equal(h.elements.get('ytcheck').disabled,false);
  pass('Failed OAuth check cannot start and restores the controls');

  h=harness();let release;
  h.state.blockCheck=new Promise(resolve=>{release=resolve});
  const first=h.click('ytstart');await Promise.resolve();await h.click('ytstart');release();await first;
  assert.equal(h.state.requests.filter(r=>r.url==='/api/youtube/start').length,1);
  assert.equal(h.state.requests.filter(r=>r.url==='/api/youtube/check').length,1);
  pass('Double-click while OAuth is pending sends only one check and one start');

  h=harness();await h.click('ytcheck');await h.click('logout');
  assert.equal(h.read('checkedChannel'),null);
  assert.equal(h.read('authHeader'),'');
  assert.equal(h.elements.get('ytadopt').hidden,true);
  pass('Logout clears the checked channel and credentials');

  console.log('\n'+passed+' dashboard regressions passed');
})().catch(err=>{console.error(err);process.exitCode=1});