@volter/twin-x 0.1.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.
- package/LICENSE +202 -0
- package/README.md +138 -0
- package/dist/client/x-mirror.bundle.js +321 -0
- package/dist/client/x-mirror.d.ts +45 -0
- package/dist/client/x-mirror.js +417 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +29 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +68 -0
- package/dist/src/x-budget.d.ts +54 -0
- package/dist/src/x-budget.js +123 -0
- package/dist/src/x-capabilities.d.ts +3 -0
- package/dist/src/x-capabilities.js +1106 -0
- package/dist/src/x-conformance.d.ts +8 -0
- package/dist/src/x-conformance.js +91 -0
- package/dist/src/x-connector.d.ts +125 -0
- package/dist/src/x-connector.js +546 -0
- package/dist/src/x-media.d.ts +87 -0
- package/dist/src/x-media.js +275 -0
- package/dist/src/x-mirror-ui.d.ts +61 -0
- package/dist/src/x-mirror-ui.js +253 -0
- package/dist/src/x-problems.d.ts +38 -0
- package/dist/src/x-problems.js +130 -0
- package/dist/src/x-scopes.d.ts +7 -0
- package/dist/src/x-scopes.js +62 -0
- package/dist/src/x-server.d.ts +14 -0
- package/dist/src/x-server.js +127 -0
- package/dist/src/x-twin.d.ts +21 -0
- package/dist/src/x-twin.js +1534 -0
- package/package.json +58 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +132 -0
- package/src/x-budget.ts +150 -0
- package/src/x-capabilities.ts +1161 -0
- package/src/x-conformance.ts +113 -0
- package/src/x-connector.ts +546 -0
- package/src/x-media.ts +295 -0
- package/src/x-mirror-ui.ts +263 -0
- package/src/x-problems.ts +143 -0
- package/src/x-scopes.ts +67 -0
- package/src/x-server.ts +126 -0
- package/src/x-twin.ts +1545 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Spec conformance for the X posting surface — dev-only, imported LAZILY by cli.ts so it never
|
|
2
|
+
// enters the pack's runtime entrypoint graph.
|
|
3
|
+
//
|
|
4
|
+
// What it checks is what a client's own code depends on and a status-only check would miss: the
|
|
5
|
+
// vendor's response SHAPES (create is thin, the mentions timeline OMITS `data` when empty), and
|
|
6
|
+
// the negative paths (a bad bearer is the about:blank problem, an unmodelled /2 route is a 404
|
|
7
|
+
// problem, a reply to a post that does not exist is `resource-not-found`).
|
|
8
|
+
import { handleXTwinRequest } from './x-twin.ts';
|
|
9
|
+
|
|
10
|
+
export type XConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
|
|
11
|
+
|
|
12
|
+
export async function checkXConformance(options: { root?: string } = {}): Promise<XConformanceReport> {
|
|
13
|
+
const failures: string[] = [];
|
|
14
|
+
const check = (name: string, ok: boolean) => { if (!ok) failures.push(name); };
|
|
15
|
+
const root = options.root;
|
|
16
|
+
const auth = { authorization: 'Bearer conformance-token' };
|
|
17
|
+
const h = (method: string, path: string, body?: unknown, headers: Record<string, string> = auth) =>
|
|
18
|
+
handleXTwinRequest({ method, path, headers, root, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
19
|
+
|
|
20
|
+
await h('POST', '/_twin/accounts', { id: '1000', username: 'orgvoice', name: 'Org Voice' }, {});
|
|
21
|
+
await h('POST', '/_twin/accounts', { id: '2000', username: 'member' }, {});
|
|
22
|
+
await h('POST', '/_twin/tokens', { token: 'conformance-token', account_id: '1000', scopes: ['tweet.read', 'tweet.write', 'users.read'] }, {});
|
|
23
|
+
|
|
24
|
+
const created = await h('POST', '/2/tweets', { text: 'hello world' });
|
|
25
|
+
const createdBody = created.body as any;
|
|
26
|
+
check('create answers 201 with the thin {id, text, edit_history_tweet_ids} data envelope', created.status === 201
|
|
27
|
+
&& typeof createdBody?.data?.id === 'string'
|
|
28
|
+
&& createdBody.data.text === 'hello world'
|
|
29
|
+
&& Array.isArray(createdBody.data.edit_history_tweet_ids)
|
|
30
|
+
&& createdBody.data.edit_history_tweet_ids[0] === createdBody.data.id
|
|
31
|
+
&& Object.keys(createdBody.data).length === 3);
|
|
32
|
+
|
|
33
|
+
const emptyMentions = await h('GET', '/2/users/1000/mentions');
|
|
34
|
+
check('an EMPTY mentions timeline omits `data` and answers with meta.result_count 0', emptyMentions.status === 200
|
|
35
|
+
&& (emptyMentions.body as any)?.meta?.result_count === 0
|
|
36
|
+
&& (emptyMentions.body as any)?.data === undefined);
|
|
37
|
+
|
|
38
|
+
await h('POST', '/_twin/posts', { author_id: '2000', text: 'hey @orgvoice can you help' }, {});
|
|
39
|
+
const mentions = await h('GET', '/2/users/1000/mentions');
|
|
40
|
+
const mentionsBody = mentions.body as any;
|
|
41
|
+
check('a mentions timeline carries data[] plus meta.newest_id/oldest_id', mentions.status === 200
|
|
42
|
+
&& Array.isArray(mentionsBody?.data) && mentionsBody.data.length === 1
|
|
43
|
+
&& mentionsBody.meta?.result_count === 1
|
|
44
|
+
&& mentionsBody.meta?.newest_id === mentionsBody.data[0].id
|
|
45
|
+
&& mentionsBody.meta?.oldest_id === mentionsBody.data[0].id);
|
|
46
|
+
|
|
47
|
+
const replied = await h('POST', '/2/tweets', { text: 'happy to help', reply: { in_reply_to_tweet_id: mentionsBody.data[0].id } });
|
|
48
|
+
check('a reply answers 201 with the same thin envelope a post does', replied.status === 201 && typeof (replied.body as any)?.data?.id === 'string');
|
|
49
|
+
|
|
50
|
+
const deleted = await h('DELETE', `/2/tweets/${(replied.body as any).data.id}`);
|
|
51
|
+
check('delete answers {data:{deleted:true}}', deleted.status === 200 && (deleted.body as any)?.data?.deleted === true);
|
|
52
|
+
|
|
53
|
+
const noAuth = await h('GET', '/2/users/1000/mentions', undefined, {});
|
|
54
|
+
check('an absent bearer is the about:blank 401 problem', noAuth.status === 401
|
|
55
|
+
&& (noAuth.body as any)?.type === 'about:blank'
|
|
56
|
+
&& (noAuth.body as any)?.title === 'Unauthorized');
|
|
57
|
+
|
|
58
|
+
const unmodelled = await h('GET', '/2/tweets/counts/recent?query=x');
|
|
59
|
+
check('an UNMODELLED /2 route fails like the vendor (404 problem), never a fake success', unmodelled.status === 404
|
|
60
|
+
&& (unmodelled.body as any)?.title === 'Not Found Error');
|
|
61
|
+
|
|
62
|
+
const orphanReply = await h('POST', '/2/tweets', { text: 'to nobody', reply: { in_reply_to_tweet_id: '404404404404404404' } });
|
|
63
|
+
check('a reply to a post that does not exist is the resource-not-found problem', orphanReply.status === 404
|
|
64
|
+
&& (orphanReply.body as any)?.errors?.[0]?.resource_type === 'tweet'
|
|
65
|
+
&& (orphanReply.body as any)?.errors?.[0]?.parameter === 'reply.in_reply_to_tweet_id');
|
|
66
|
+
|
|
67
|
+
const noText = await h('POST', '/2/tweets', {});
|
|
68
|
+
check('a create with no text is the invalid-request problem', noText.status === 400
|
|
69
|
+
&& (noText.body as any)?.type === 'https://api.x.com/2/problems/invalid-request');
|
|
70
|
+
|
|
71
|
+
const looked = await h('GET', `/2/tweets/${createdBody.data.id}`);
|
|
72
|
+
check('post lookup answers the DEFAULT projection — id, text, edit_history_tweet_ids and nothing else', looked.status === 200
|
|
73
|
+
&& Object.keys((looked.body as any)?.data ?? {}).sort().join(',') === 'edit_history_tweet_ids,id,text'
|
|
74
|
+
&& (looked.body as any).data.id === createdBody.data.id);
|
|
75
|
+
|
|
76
|
+
const projected = await h('GET', `/2/tweets/${createdBody.data.id}?tweet.fields=author_id,conversation_id`);
|
|
77
|
+
check('tweet.fields adds exactly the fields asked for', projected.status === 200
|
|
78
|
+
&& (projected.body as any)?.data?.author_id === '1000'
|
|
79
|
+
&& (projected.body as any).data.conversation_id === createdBody.data.id);
|
|
80
|
+
|
|
81
|
+
const ghost = await h('GET', '/2/tweets/404404404404404404');
|
|
82
|
+
check('a lookup of an id nobody created answers `errors`, never a fabricated `data`', ghost.status === 200
|
|
83
|
+
&& (ghost.body as any)?.data === undefined
|
|
84
|
+
&& (ghost.body as any)?.errors?.[0]?.resource_type === 'tweet'
|
|
85
|
+
&& (ghost.body as any).errors[0].value === '404404404404404404');
|
|
86
|
+
|
|
87
|
+
const bulk = await h('GET', `/2/tweets?ids=${createdBody.data.id},404404404404404404`);
|
|
88
|
+
check('a bulk lookup is PARTIAL: the id that exists in data, the one that does not in errors', bulk.status === 200
|
|
89
|
+
&& (bulk.body as any)?.data?.length === 1
|
|
90
|
+
&& (bulk.body as any).data[0].id === createdBody.data.id
|
|
91
|
+
&& (bulk.body as any)?.errors?.length === 1);
|
|
92
|
+
|
|
93
|
+
const own = await h('GET', '/2/users/1000/tweets');
|
|
94
|
+
check("the author's own timeline carries the post it made", own.status === 200
|
|
95
|
+
&& ((own.body as any)?.data ?? []).some((row: any) => row.id === createdBody.data.id));
|
|
96
|
+
|
|
97
|
+
const searched = await h('GET', '/2/tweets/search/recent?query=hello');
|
|
98
|
+
const searchUnsupported = await h('GET', '/2/tweets/search/recent?query=hello OR world');
|
|
99
|
+
check('recent search matches on the stored text, and refuses grammar the twin does not model', searched.status === 200
|
|
100
|
+
&& ((searched.body as any)?.data ?? []).some((row: any) => row.id === createdBody.data.id)
|
|
101
|
+
&& searchUnsupported.status === 400
|
|
102
|
+
&& (searchUnsupported.body as any)?.errors?.[0]?.parameters?.query?.[0] === 'OR');
|
|
103
|
+
|
|
104
|
+
const foreignHome = await h('GET', '/2/users/2000/timelines/reverse_chronological');
|
|
105
|
+
check("the home timeline is refused for an account that is not the bearer's", foreignHome.status === 403
|
|
106
|
+
&& (foreignHome.body as any)?.title === 'Forbidden');
|
|
107
|
+
|
|
108
|
+
const tooLong = await h('POST', '/2/tweets', { text: 'x'.repeat(281) });
|
|
109
|
+
check('a post over the account character limit is the invalid-request problem', tooLong.status === 400
|
|
110
|
+
&& (tooLong.body as any)?.errors?.[0]?.parameters?.text?.[0] === '281');
|
|
111
|
+
|
|
112
|
+
return { ok: failures.length === 0, checksRun: 18, failures };
|
|
113
|
+
}
|