/ clis / zhihu / write-shared.test.js
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  });