@framers/agentos-ext-letterboxd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * LetterboxdMovieTool — Unit Tests
4
+ *
5
+ * Verifies the tool's metadata (name, category, inputSchema), the built-in
6
+ * fallback scraper with mocked fetch returning search results HTML and film
7
+ * page HTML, and the `found: false` path when no search results match.
8
+ *
9
+ * All HTTP calls are mocked via `globalThis.fetch` — no real Letterboxd
10
+ * requests are made.
11
+ *
12
+ * @module test/letterboxdMovie
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { LetterboxdMovieTool } from '../src/tools/letterboxdMovie.js';
17
+
18
+ /* -------------------------------------------------------------------------- */
19
+ /* Global fetch mock */
20
+ /* -------------------------------------------------------------------------- */
21
+
22
+ const mockFetch = vi.fn();
23
+ vi.stubGlobal('fetch', mockFetch);
24
+
25
+ beforeEach(() => {
26
+ mockFetch.mockReset();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ /* -------------------------------------------------------------------------- */
34
+ /* Minimal ToolExecutionContext stub */
35
+ /* -------------------------------------------------------------------------- */
36
+
37
+ /** Minimal context object satisfying the ToolExecutionContext interface. */
38
+ const stubContext = {
39
+ agentId: 'test-agent',
40
+ conversationId: 'test-conversation',
41
+ } as any;
42
+
43
+ /* -------------------------------------------------------------------------- */
44
+ /* HTML fixtures */
45
+ /* -------------------------------------------------------------------------- */
46
+
47
+ /**
48
+ * Simulated Letterboxd search results page HTML containing a link to the
49
+ * film "Arrival (2016)".
50
+ */
51
+ const SEARCH_RESULTS_HTML = `
52
+ <!DOCTYPE html>
53
+ <html>
54
+ <head><title>Letterboxd - Search</title></head>
55
+ <body>
56
+ <ul class="results">
57
+ <li class="search-result">
58
+ <a href="/film/arrival-2016/" class="film-poster">
59
+ <img src="https://letterboxd.com/poster.jpg" alt="Arrival">
60
+ </a>
61
+ <span class="film-title-wrapper">
62
+ <a href="/film/arrival-2016/">Arrival</a>
63
+ <small class="metadata">2016</small>
64
+ </span>
65
+ </li>
66
+ <li class="search-result">
67
+ <a href="/film/arrival-1996/" class="film-poster">
68
+ <img src="https://letterboxd.com/poster2.jpg" alt="Arrival">
69
+ </a>
70
+ <span class="film-title-wrapper">
71
+ <a href="/film/arrival-1996/">The Arrival</a>
72
+ <small class="metadata">1996</small>
73
+ </span>
74
+ </li>
75
+ </ul>
76
+ </body>
77
+ </html>
78
+ `;
79
+
80
+ /**
81
+ * Simulated Letterboxd film page HTML for "Arrival (2016)".
82
+ */
83
+ const FILM_PAGE_HTML = `
84
+ <!DOCTYPE html>
85
+ <html>
86
+ <head>
87
+ <meta property="og:title" content="Arrival (2016)">
88
+ <meta property="og:description" content="A linguist is recruited by the military to communicate with alien lifeforms.">
89
+ <meta name="twitter:data1" content="Denis Villeneuve">
90
+ <meta name="twitter:data2" content="4.0 out of 5">
91
+ </head>
92
+ <body>
93
+ <section id="featured-film-header">
94
+ <h1 class="headline-1 filmtitle">Arrival</h1>
95
+ <small class="number"><a href="/films/year/2016/">2016</a></small>
96
+ </section>
97
+
98
+ <div class="text-sluglist">
99
+ <a class="text-slug" href="/films/genre/drama/">Drama</a>
100
+ <a class="text-slug" href="/films/genre/science-fiction/">Science Fiction</a>
101
+ </div>
102
+
103
+ <div class="truncate"><p>A linguist is recruited by the military to communicate with alien lifeforms.</p></div>
104
+
105
+ <ul class="film-details">
106
+ <li class="film-detail">
107
+ <a class="context" href="/johndoe/film/arrival-2016/">johndoe</a>
108
+ <span class="rating rated-large-8">★★★★</span>
109
+ <div class="body-text -micro"><p>Absolutely breathtaking.</p></div>
110
+ </li>
111
+ <li class="film-detail">
112
+ <a class="context" href="/janedoe/film/arrival-2016/">janedoe</a>
113
+ <span class="rating rated-large-9">★★★★½</span>
114
+ <div class="body-text -micro"><p>One of the best sci-fi films ever made.</p></div>
115
+ </li>
116
+ </ul>
117
+
118
+ <section id="crew-credits">
119
+ <a href="/director/denis-villeneuve/">Denis Villeneuve</a>
120
+ </section>
121
+ </body>
122
+ </html>
123
+ `;
124
+
125
+ /** Search results HTML with no film links (empty results). */
126
+ const EMPTY_SEARCH_HTML = `
127
+ <!DOCTYPE html>
128
+ <html>
129
+ <head><title>Letterboxd - Search</title></head>
130
+ <body>
131
+ <div class="results">
132
+ <p>There were no matches for your search term.</p>
133
+ </div>
134
+ </body>
135
+ </html>
136
+ `;
137
+
138
+ /* -------------------------------------------------------------------------- */
139
+ /* Tool metadata */
140
+ /* -------------------------------------------------------------------------- */
141
+
142
+ describe('LetterboxdMovieTool — metadata', () => {
143
+ const tool = new LetterboxdMovieTool();
144
+
145
+ it('should have the correct tool name', () => {
146
+ expect(tool.name).toBe('letterboxd_movie');
147
+ });
148
+
149
+ it('should have the correct category', () => {
150
+ expect(tool.category).toBe('media');
151
+ });
152
+
153
+ it('should define an inputSchema requiring "title"', () => {
154
+ expect(tool.inputSchema).toBeDefined();
155
+ expect(tool.inputSchema.type).toBe('object');
156
+ expect(tool.inputSchema.properties).toBeDefined();
157
+ expect((tool.inputSchema.properties as any).title).toBeDefined();
158
+ expect((tool.inputSchema.properties as any).title.type).toBe('string');
159
+ expect(tool.inputSchema.required).toContain('title');
160
+ });
161
+
162
+ it('should declare hasSideEffects as false', () => {
163
+ expect(tool.hasSideEffects).toBe(false);
164
+ });
165
+
166
+ it('should have an id, displayName, and version', () => {
167
+ expect(tool.id).toBe('letterboxd-movie-v1');
168
+ expect(tool.displayName).toBe('Letterboxd Movie Lookup');
169
+ expect(tool.version).toBe('1.0.0');
170
+ });
171
+ });
172
+
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Successful film lookup */
175
+ /* -------------------------------------------------------------------------- */
176
+
177
+ describe('LetterboxdMovieTool — built-in fallback scraper', () => {
178
+ const tool = new LetterboxdMovieTool();
179
+
180
+ it('should return found: true with film data from mocked HTML', async () => {
181
+ // First fetch: search results page
182
+ mockFetch.mockResolvedValueOnce({
183
+ ok: true,
184
+ status: 200,
185
+ text: async () => SEARCH_RESULTS_HTML,
186
+ });
187
+
188
+ // Second fetch: film detail page
189
+ mockFetch.mockResolvedValueOnce({
190
+ ok: true,
191
+ status: 200,
192
+ text: async () => FILM_PAGE_HTML,
193
+ });
194
+
195
+ const result = await tool.execute({ title: 'Arrival', year: 2016 }, stubContext);
196
+
197
+ expect(result.success).toBe(true);
198
+ expect(result.output).toBeDefined();
199
+ expect(result.output!.found).toBe(true);
200
+ expect(result.output!.title).toBe('Arrival');
201
+ expect(result.output!.year).toBe('2016');
202
+ expect(result.output!.rating).toBe('4.0');
203
+ expect(result.output!.directors).toBe('Denis Villeneuve');
204
+ expect(result.output!.genres).toContain('Drama');
205
+ expect(result.output!.genres).toContain('Science Fiction');
206
+ expect(result.output!.synopsis).toContain('linguist');
207
+ });
208
+
209
+ it('should extract user reviews from the film page', async () => {
210
+ mockFetch.mockResolvedValueOnce({
211
+ ok: true,
212
+ status: 200,
213
+ text: async () => SEARCH_RESULTS_HTML,
214
+ });
215
+ mockFetch.mockResolvedValueOnce({
216
+ ok: true,
217
+ status: 200,
218
+ text: async () => FILM_PAGE_HTML,
219
+ });
220
+
221
+ const result = await tool.execute({ title: 'Arrival' }, stubContext);
222
+
223
+ expect(result.output!.reviews).toBeDefined();
224
+ expect(result.output!.reviews!.length).toBeGreaterThanOrEqual(1);
225
+
226
+ const firstReview = result.output!.reviews![0]!;
227
+ expect(firstReview.user).toBe('johndoe');
228
+ expect(firstReview.text).toContain('breathtaking');
229
+ });
230
+ });
231
+
232
+ /* -------------------------------------------------------------------------- */
233
+ /* No search results */
234
+ /* -------------------------------------------------------------------------- */
235
+
236
+ describe('LetterboxdMovieTool — no results', () => {
237
+ const tool = new LetterboxdMovieTool();
238
+
239
+ it('should return found: false when no search results match and direct URL also fails', async () => {
240
+ // Search returns empty results
241
+ mockFetch.mockResolvedValueOnce({
242
+ ok: true,
243
+ status: 200,
244
+ text: async () => EMPTY_SEARCH_HTML,
245
+ });
246
+
247
+ // Direct URL attempt also fails (404)
248
+ mockFetch.mockResolvedValueOnce({
249
+ ok: false,
250
+ status: 404,
251
+ text: async () => 'Not Found',
252
+ });
253
+
254
+ const result = await tool.execute({ title: 'xyznonexistent123' }, stubContext);
255
+
256
+ expect(result.success).toBe(true);
257
+ expect(result.output).toBeDefined();
258
+ expect(result.output!.found).toBe(false);
259
+ });
260
+
261
+ it('should return an error when title is empty', async () => {
262
+ const result = await tool.execute({ title: '' }, stubContext);
263
+ expect(result.success).toBe(false);
264
+ expect(result.error).toContain('title');
265
+ });
266
+
267
+ it('should return found: false with error message when search is blocked by Cloudflare', async () => {
268
+ // Search returns 403 (Cloudflare blocks)
269
+ mockFetch.mockResolvedValueOnce({
270
+ ok: false,
271
+ status: 403,
272
+ text: async () => 'Forbidden',
273
+ });
274
+
275
+ // Direct URL attempt also fails
276
+ mockFetch.mockResolvedValueOnce({
277
+ ok: false,
278
+ status: 404,
279
+ text: async () => 'Not Found',
280
+ });
281
+
282
+ const result = await tool.execute({ title: 'Arrival' }, stubContext);
283
+
284
+ expect(result.success).toBe(true);
285
+ expect(result.output!.found).toBe(false);
286
+ expect(result.error).toContain('Cloudflare');
287
+ });
288
+
289
+ it('should fall back to direct URL when search fails and return film data', async () => {
290
+ // Search returns 403 (Cloudflare)
291
+ mockFetch.mockResolvedValueOnce({
292
+ ok: false,
293
+ status: 403,
294
+ text: async () => 'Forbidden',
295
+ });
296
+
297
+ // Direct URL attempt succeeds (film detail pages bypass Cloudflare)
298
+ mockFetch.mockResolvedValueOnce({
299
+ ok: true,
300
+ status: 200,
301
+ text: async () => FILM_PAGE_HTML,
302
+ });
303
+
304
+ const result = await tool.execute({ title: 'Arrival' }, stubContext);
305
+
306
+ expect(result.success).toBe(true);
307
+ expect(result.output).toBeDefined();
308
+ expect(result.output!.found).toBe(true);
309
+ expect(result.output!.title).toBe('Arrival');
310
+ expect(result.output!.rating).toBe('4.0');
311
+ });
312
+ });
313
+
314
+ /* -------------------------------------------------------------------------- */
315
+ /* Argument validation */
316
+ /* -------------------------------------------------------------------------- */
317
+
318
+ describe('LetterboxdMovieTool — validateArgs', () => {
319
+ const tool = new LetterboxdMovieTool();
320
+
321
+ it('should pass validation with valid title', () => {
322
+ const result = tool.validateArgs({ title: 'Arrival' });
323
+ expect(result.isValid).toBe(true);
324
+ expect(result.errors).toBeUndefined();
325
+ });
326
+
327
+ it('should fail validation when title is missing', () => {
328
+ const result = tool.validateArgs({});
329
+ expect(result.isValid).toBe(false);
330
+ expect(result.errors).toBeDefined();
331
+ expect(result.errors!.length).toBeGreaterThan(0);
332
+ });
333
+
334
+ it('should fail validation when year is not an integer', () => {
335
+ const result = tool.validateArgs({ title: 'Arrival', year: 2016.5 });
336
+ expect(result.isValid).toBe(false);
337
+ expect(result.errors).toBeDefined();
338
+ });
339
+
340
+ it('should pass validation with title and integer year', () => {
341
+ const result = tool.validateArgs({ title: 'Arrival', year: 2016 });
342
+ expect(result.isValid).toBe(true);
343
+ });
344
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "noEmitOnError": false,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "resolveJsonModule": true,
17
+ "moduleResolution": "Bundler",
18
+ "types": ["node"]
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist", "test"]
22
+ }