summaryrefslogtreecommitdiff
path: root/modules/test/test-wpm-index.js
blob: ae9cc3544bb3bfc3f72035628c28cd7bcd76c8bc (plain)
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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
// node modules/test/test-wpm-index.js
'use strict';
// Regression test — RPT-20261109-123907-5a6273ab (source tool: wpm_search)
//
// packages.json `kanban` was authored with `description` while the other 31 entries
// use `desc`, and modules/ext/wpm.js read `pkg.desc.toLowerCase()` inside search().
// Result: EVERY `wpm search <kw>` threw
//   TypeError: Cannot read properties of undefined (reading 'toLowerCase')
// for any keyword (pdf, git, zzz) because the bad entry sits mid-loop.
//
// This test loads the real served catalog + the real wpm.js (no network, no browser)
// and asserts that search/list/info/remove/install cannot throw on it, and that an
// entry with no desc/category at all is also survivable.
//
// Run: node modules/test/test-wpm-index.js

const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..', '..');
const WPM_SRC = path.join(ROOT, 'modules', 'ext', 'wpm.js');
const CATALOG = path.join(ROOT, 'packages.json');

let checks = 0;
let failures = 0;
function ok(cond, label) {
    checks++;
    if (cond) { console.log('  ok   ' + label); return true; }
    failures++;
    console.error('  FAIL ' + label);
    return false;
}

// ── Load wpm.js exactly as the browser would, minus the browser ──────────────
// window is an empty object (a real browser has window.webx11 undefined until
// module-loader.js runs), so the self-registration block at the bottom is skipped.
const code = fs.readFileSync(WPM_SRC, 'utf8');
const store = {};
const fakeLocalStorage = {
    getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
    setItem: (k, v) => { store[k] = String(v); },
    removeItem: (k) => { delete store[k]; },
};
const noNetwork = () => Promise.reject(new Error('network disabled in test'));
const WpmModule = new Function('window', 'localStorage', 'fetch', code + '\n;return WpmModule;')
    ({}, fakeLocalStorage, noNetwork);

function makeWpm(catalog) {
    const logs = [];
    const m = new WpmModule();
    m.terminal = {
        print: (...args) => { logs.push(args.map((a) => String(a)).join(' ')); },
        moduleManager: {
            loadModuleFromURL: async () => true,
            unregisterModule: () => true,
        },
    };
    m.installed = {};
    m.saveInstalled = () => {};
    // Pre-seed the index: loadIndex() returns this.index and never fetches.
    m.index = catalog || JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
    return { m, logs };
}

async function main() {
    const raw = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
    const catalog = raw.packages;
    const names = Object.keys(catalog);

    console.log('\n[1] catalog shape + normalisation (' + names.length + ' packages)');
    ok(names.length === 32, 'catalog still has 32 packages');
    ok(!!catalog.kanban, 'kanban entry exists (the entry that broke search)');

    const { m: probe } = makeWpm();
    const map = probe._packageMap(probe.index);

    const missingDesc = names.filter((n) => !map[n] || typeof map[n].desc !== 'string' || map[n].desc.length === 0);
    const missingCat = names.filter((n) => !map[n] || typeof map[n].category !== 'string' || map[n].category.length === 0);
    ok(missingDesc.length === 0, 'every entry normalises to a non-empty string desc' + (missingDesc.length ? ' — bad: ' + missingDesc.join(',') : ''));
    ok(missingCat.length === 0, 'every entry normalises to a non-empty string category' + (missingCat.length ? ' — bad: ' + missingCat.join(',') : ''));
    names.forEach((n) => {
        ok(typeof catalog[n].url === 'string' && catalog[n].url.length > 0, n + ': raw url present');
    });
    ok(typeof catalog.kanban.desc === 'string' && catalog.kanban.desc.length > 0,
        'kanban now carries the canonical `desc` field');
    ok(catalog.kanban.description === undefined || catalog.kanban.description === catalog.kanban.desc,
        'kanban legacy `description` (kept for other readers) agrees with desc');

    console.log('\n[2] search over the real catalog for the keywords that used to throw');
    for (const kw of ['pdf', 'git', 'zzz']) {
        const { m, logs } = makeWpm();
        let err = null;
        try { await m.search(kw); } catch (e) { err = e; }
        ok(!err, 'search("' + kw + '") did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
        ok(logs.length > 0, 'search("' + kw + '") printed something (' + logs.length + ' lines)');
        ok(!logs.some((l) => l.includes('undefined')), 'search("' + kw + '") printed no "undefined"');
        if (kw === 'pdf') {
            // search() prints the entry's display `name` ("File Manager"), not the map key.
            ok(logs.some((l) => /pdf/i.test(l) && l.includes('File Manager')), 'search("pdf") finds the PDF-previewing file manager');
        } else {
            ok(logs.some((l) => l.includes('No packages matching')), 'search("' + kw + '") reports no match cleanly');
        }
    }

    console.log('\n[3] the other catalog readers (list / info / remove / install) survive kanban');
    {
        const { m, logs } = makeWpm();
        let err = null;
        try {
            await m.list();
            await m.info('kanban');
        } catch (e) { err = e; }
        ok(!err, 'list()+info("kanban") did not throw' + (err ? ' — ' + err.message : ''));
        ok(!logs.some((l) => l.includes('undefined')), 'list()+info("kanban") printed no "undefined"');
        ok(logs.some((l) => l.includes('kanban - ')), 'list() renders the kanban line');

        m.installed.kanban = { version: '1.0.0', installed: '2026-11-09T00:00:00Z' };
        let rmErr = null;
        try { await m.remove('kanban'); } catch (e) { rmErr = e; }
        ok(!rmErr, 'remove("kanban") did not throw (name falls back to the map key)' + (rmErr ? ' — ' + rmErr.message : ''));

        const inst = makeWpm();
        let inErr = null;
        try { await inst.m.install('kanban'); } catch (e) { inErr = e; }
        ok(!inErr, 'install("kanban") did not throw (catalog fetch is stubbed to fail)');
        ok(!inst.logs.some((l) => l.includes('undefined')), 'install("kanban") printed no "undefined"');
        ok(inst.logs.some((l) => l.includes('Installing Kanban Board v1.0.0')), 'install("kanban") prints the normalised name/version');
    }

    console.log('\n[4] an entry with no desc/category/name/version at all cannot throw');
    {
        const { m, logs } = makeWpm();
        m.index.packages.brokenpkg = { url: 'modules/ext/brokenpkg.js' };
        let err = null;
        try {
            await m.search('zzz');
            await m.search('broken');
            await m.list();
            await m.info('brokenpkg');
        } catch (e) { err = e; }
        ok(!err, 'field-less entry: search/list/info did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
        ok(logs.length > 0, 'field-less entry: something was printed');
        ok(!logs.some((l) => l.includes('undefined')), 'field-less entry: no "undefined" leaked to the terminal');
        ok(logs.some((l) => l.includes('brokenpkg [')), 'field-less entry: search hit renders empty category, not a crash');
    }

    // also: an index whose `packages` key is missing entirely
    console.log('\n[5] malformed index (no `packages` object) cannot throw');
    {
        const { m, logs } = makeWpm({ name: 'broken-index' });
        let err = null;
        try { await m.search('pdf'); await m.list(); } catch (e) { err = e; }
        ok(!err, 'index without packages: search/list did not throw' + (err ? ' — ' + err.message : ''));
        ok(logs.some((l) => l.includes('No packages matching')), 'index without packages: clean "no match" output');
    }

    console.log('\n' + (failures ? 'FAIL' : 'PASS') + ' — ' + (checks - failures) + '/' + checks + ' checks');
    process.exit(failures ? 1 : 0);
}

main().catch((e) => {
    console.error('FAIL — unexpected error: ' + (e && e.stack || e));
    process.exit(1);
});