write-shared.test.js
1 import { mkdtemp, rm, writeFile } from 'node:fs/promises'; 2 import { join } from 'node:path'; 3 import { tmpdir } from 'node:os'; 4 import { describe, expect, it, vi } from 'vitest'; 5 import { CliError } from '@jackwener/opencli/errors'; 6 import { __test__ } from './write-shared.js'; 7 class FakeNode { 8 attrs; 9 textContent; 10 hasAvatar; 11 constructor(attrs, textContent = null, hasAvatar = false) { 12 this.attrs = attrs; 13 this.textContent = textContent; 14 this.hasAvatar = hasAvatar; 15 } 16 getAttribute(name) { 17 return this.attrs[name] ?? null; 18 } 19 querySelector(selector) { 20 if (this.hasAvatar && selector.includes('img')) 21 return {}; 22 return null; 23 } 24 } 25 class FakeRoot { 26 selectors; 27 constructor(selectors) { 28 this.selectors = selectors; 29 } 30 querySelectorAll(selector) { 31 return this.selectors[selector] ?? []; 32 } 33 } 34 function createPageForDom(documentRoot, state = undefined) { 35 return { 36 evaluate: vi.fn().mockImplementation(async (js) => { 37 const previousDocument = globalThis.document; 38 const previousWindow = globalThis.window; 39 const previousState = globalThis.__INITIAL_STATE__; 40 const windowObject = { __INITIAL_STATE__: state }; 41 try { 42 Object.assign(globalThis, { 43 document: documentRoot, 44 window: windowObject, 45 __INITIAL_STATE__: state, 46 }); 47 return globalThis.eval(js); 48 } 49 finally { 50 Object.assign(globalThis, { 51 document: previousDocument, 52 window: previousWindow, 53 __INITIAL_STATE__: previousState, 54 }); 55 } 56 }), 57 }; 58 } 59 describe('zhihu write shared helpers', () => { 60 it('rejects missing --execute', () => { 61 expect(() => __test__.requireExecute({})).toThrowError(CliError); 62 }); 63 it('accepts a non-empty text payload', async () => { 64 await expect(__test__.resolvePayload({ text: 'hello' })).resolves.toBe('hello'); 65 }); 66 it('rejects whitespace-only payloads', async () => { 67 await expect(__test__.resolvePayload({ text: ' ' })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); 68 }); 69 it('rejects missing file payloads as INVALID_INPUT', async () => { 70 await expect(__test__.resolvePayload({ file: join(tmpdir(), 'zhihu-write-shared-missing.txt') })).rejects.toMatchObject({ 71 code: 'INVALID_INPUT', 72 message: expect.stringContaining('File not found'), 73 }); 74 }); 75 it('rejects invalid UTF-8 file payloads as INVALID_INPUT', async () => { 76 const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-')); 77 const file = join(dir, 'payload.txt'); 78 await writeFile(file, Buffer.from([0xc3, 0x28])); 79 try { 80 await expect(__test__.resolvePayload({ file })).rejects.toMatchObject({ 81 code: 'INVALID_INPUT', 82 message: expect.stringContaining('decoded as UTF-8'), 83 }); 84 } 85 finally { 86 await rm(dir, { recursive: true, force: true }); 87 } 88 }); 89 it('rejects generic file read failures as INVALID_INPUT', async () => { 90 const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-')); 91 const file = join(dir, 'payload.txt'); 92 await writeFile(file, 'hello'); 93 try { 94 await expect(__test__.resolvePayload({ file }, { 95 stat: async () => ({ isFile: () => true }), 96 readFile: async () => { 97 throw new Error('boom'); 98 }, 99 decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw), 100 })).rejects.toMatchObject({ 101 code: 'INVALID_INPUT', 102 message: expect.stringContaining('could not be read'), 103 }); 104 } 105 finally { 106 await rm(dir, { recursive: true, force: true }); 107 } 108 }); 109 it('prefers the state slug before DOM fallback', async () => { 110 const documentRoot = new FakeRoot({ 111 'header, nav, [role="banner"], [role="navigation"]': [], 112 'a[href^="/people/"]': [new FakeNode({ href: '/people/not-used', 'data-testid': 'profile-link' }, null, true)], 113 }); 114 expect(__test__.resolveCurrentUserSlugFromDom({ me: { slug: 'alice' } }, documentRoot)).toBe('alice'); 115 }); 116 it('accepts nav avatar links as a conservative fallback', async () => { 117 const navRoot = new FakeRoot({ 118 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)], 119 }); 120 const documentRoot = new FakeRoot({ 121 'header, nav, [role="banner"], [role="navigation"]': [navRoot], 122 'a[href^="/people/"]': [], 123 }); 124 expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice'); 125 }); 126 it('accepts document-wide fallback only for explicit account/profile signals', async () => { 127 const documentRoot = new FakeRoot({ 128 'header, nav, [role="banner"], [role="navigation"]': [], 129 'a[href^="/people/"]': [ 130 new FakeNode({ href: '/people/alice', 'data-testid': 'account-profile-link' }), 131 ], 132 }); 133 expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice'); 134 }); 135 it('does not accept a document-wide author avatar link as current-user fallback', async () => { 136 const documentRoot = new FakeRoot({ 137 'header, nav, [role="banner"], [role="navigation"]': [], 138 'a[href^="/people/"]': [new FakeNode({ href: '/people/author-1' }, 'Author', true)], 139 }); 140 expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull(); 141 }); 142 it('does not accept generic document metadata like user or dropdown alone', async () => { 143 const documentRoot = new FakeRoot({ 144 'header, nav, [role="banner"], [role="navigation"]': [], 145 'a[href^="/people/"]': [ 146 new FakeNode({ href: '/people/author-1', 'data-testid': 'user-menu-dropdown' }, 'Author'), 147 ], 148 }); 149 expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull(); 150 }); 151 it('freezes a stable current-user identity before write', async () => { 152 const navRoot = new FakeRoot({ 153 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)], 154 }); 155 const documentRoot = new FakeRoot({ 156 'header, nav, [role="banner"], [role="navigation"]': [navRoot], 157 'a[href^="/people/"]': [], 158 }); 159 const page = createPageForDom(documentRoot); 160 await expect(__test__.resolveCurrentUserIdentity(page)).resolves.toBe('alice'); 161 }); 162 it('rejects when current-user identity cannot be resolved', async () => { 163 const documentRoot = new FakeRoot({ 164 'header, nav, [role="banner"], [role="navigation"]': [], 165 'a[href^="/people/"]': [], 166 }); 167 const page = createPageForDom(documentRoot); 168 await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({ 169 code: 'ACTION_NOT_AVAILABLE', 170 }); 171 }); 172 it('rejects reserved buildResultRow extra keys', () => { 173 expect(() => __test__.buildResultRow('done', 'question', '123', 'applied', { status: 'oops' })).toThrowError(CliError); 174 }); 175 });