From 6ef37ea9d83a3790441b8568ddaff7466571176b Mon Sep 17 00:00:00 2001 From: Christopher Nascone Date: Wed, 6 Mar 2024 19:07:50 -0500 Subject: [PATCH] feat: transform raw data into FrameMetadata (framegear) (#205) --- framegear/package.json | 2 +- .../utils/frameResultToFrameMetadata.test.ts | 57 +++++++++++++ framegear/utils/frameResultToFrameMetadata.ts | 23 +++++ framegear/utils/validation.test.ts | 83 ++++++++++++------- framegear/utils/validation.ts | 9 +- framegear/yarn.lock | 11 +-- 6 files changed, 150 insertions(+), 35 deletions(-) create mode 100644 framegear/utils/frameResultToFrameMetadata.test.ts create mode 100644 framegear/utils/frameResultToFrameMetadata.ts diff --git a/framegear/package.json b/framegear/package.json index 5c623e1d5b..8561583aa5 100644 --- a/framegear/package.json +++ b/framegear/package.json @@ -10,7 +10,7 @@ "test": "jest" }, "dependencies": { - "@coinbase/onchainkit": "0.9.4", + "@coinbase/onchainkit": "0.10.0", "@radix-ui/react-icons": "^1.3.0", "jotai": "^2.6.4", "next": "14.1.0", diff --git a/framegear/utils/frameResultToFrameMetadata.test.ts b/framegear/utils/frameResultToFrameMetadata.test.ts new file mode 100644 index 0000000000..f3f7297deb --- /dev/null +++ b/framegear/utils/frameResultToFrameMetadata.test.ts @@ -0,0 +1,57 @@ +import { frameResultToFrameMetadata } from './frameResultToFrameMetadata'; + +describe('frameResultToFrameMetadata', () => { + const baseResult = { + 'fc:frame:button:1': 'Button 1', + 'fc:frame:button:1:action': 'Action 1', + 'fc:frame:button:1:target': 'Target 1', + 'fc:frame:image': 'Image URL', + 'fc:frame:input': 'Input Text', + 'fc:frame:post_url': 'Post URL', + 'fc:frame:state': JSON.stringify({ key: 'value' }), + 'fc:frame:refresh_period': '10', + }; + + it('should correctly map frame result to frame metadata', () => { + const metadata = frameResultToFrameMetadata(baseResult); + + expect(metadata).toEqual({ + buttons: [ + { + action: 'Action 1', + label: 'Button 1', + target: 'Target 1', + }, + undefined, + undefined, + undefined, + ], + image: 'Image URL', + input: { text: 'Input Text' }, + postUrl: 'Post URL', + state: { key: 'value' }, + refreshPeriod: 10, + }); + }); + + it('should handle missing optional fields', () => { + const result = { ...baseResult }; + delete (result as any)['fc:frame:button:1']; + delete (result as any)['fc:frame:image']; + delete (result as any)['fc:frame:input']; + delete (result as any)['fc:frame:post_url']; + delete (result as any)['fc:frame:state']; + delete (result as any)['fc:frame:refresh_period']; + + const metadata = frameResultToFrameMetadata(result); + + expect(metadata).toEqual({ + buttons: [undefined, undefined, undefined, undefined], + image: undefined, + input: undefined, + postUrl: undefined, + state: undefined, + refreshPeriod: undefined, + }); + }); +}); diff --git a/framegear/utils/frameResultToFrameMetadata.ts b/framegear/utils/frameResultToFrameMetadata.ts new file mode 100644 index 0000000000..e4f29b90d4 --- /dev/null +++ b/framegear/utils/frameResultToFrameMetadata.ts @@ -0,0 +1,23 @@ +import { FrameMetadataType } from '@coinbase/onchainkit'; + +export function frameResultToFrameMetadata(result: Record): FrameMetadataType { + const buttons = [1, 2, 3, 4].map((idx) => + result[`fc:frame:button:${idx}`] + ? { + action: result[`fc:frame:button:${idx}:action`], + label: result[`fc:frame:button:${idx}`], + target: result[`fc:frame:button:${idx}:target`], + } + : undefined, + ); + const image = result['fc:frame:image']; + const inputText = result['fc:frame:input']; + const input = inputText ? { text: inputText } : undefined; + const postUrl = result['fc:frame:post_url']; + const rawState = result['fc:frame:state']; + const rawRefreshPeriod = result['fc:frame:refresh_period']; + const refreshPeriod = rawRefreshPeriod ? parseInt(rawRefreshPeriod, 10) : undefined; + const state = rawState ? JSON.parse(result['fc:frame:state']) : undefined; + + return { buttons: buttons as any, image, input, postUrl, state, refreshPeriod }; +} diff --git a/framegear/utils/validation.test.ts b/framegear/utils/validation.test.ts index de8c4ba6f5..f833623cab 100644 --- a/framegear/utils/validation.test.ts +++ b/framegear/utils/validation.test.ts @@ -65,7 +65,7 @@ describe('schema validation', () => { ).toBe(true); }); - it('fails when button action is not "post" or "post_url"', () => { + it('fails when button action is not "post" or "post_redirect"', () => { expect( vNextSchema.isValidSync({ ...baseGoodDefinition, @@ -80,20 +80,20 @@ describe('schema validation', () => { ).toBe(false); }); - it('succeeds when button action is "post", "post_url", "mint", or "link"', () => { + it('succeeds when button action is "post", "post_redirect", "mint", or "link"', () => { expect( vNextSchema.isValidSync({ ...baseGoodDefinition, 'fc:frame:button:1': 'henlo', 'fc:frame:button:1:action': 'post', 'fc:frame:button:2': 'henlo', - 'fc:frame:button:2:action': 'post_url', + 'fc:frame:button:2:action': 'post_redirect', 'fc:frame:button:3': 'henlo', 'fc:frame:button:3:action': 'mint', 'fc:frame:button:4': 'henlo', 'fc:frame:button:4:action': 'link', }), - ).toBe(false); + ).toBe(true); }); }); @@ -150,32 +150,59 @@ describe('schema validation', () => { ).toBe(false); }); }); - }); - describe('aspect_ratio', () => { - it('succeeds when 1:1', () => { - expect( - vNextSchema.isValidSync({ - ...baseGoodDefinition, - 'fc:frame:image:aspect_ratio': '1:1', - }), - ).toBe(true); - }); - it('succeeds when 1:1', () => { - expect( - vNextSchema.isValidSync({ - ...baseGoodDefinition, - 'fc:frame:image:aspect_ratio': '1.91:1', - }), - ).toBe(true); + describe('state', () => { + it('succeeds when state is less than 4096 bytes', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:state': '{"hello": "goodbye"}', + }), + ).toBe(true); + }); + it('succeeds when state is exactly 4096 bytes', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:state': new Array(4096).fill('a').join(''), + }), + ).toBe(true); + }); + it('fails when state exceeds 4096 bytes', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:state': new Array(4097).fill('a').join(''), + }), + ).toBe(false); + }); }); - it('fails when some other value', () => { - expect( - vNextSchema.isValidSync({ - ...baseGoodDefinition, - 'fc:frame:image:aspect_ratio': '1.618:1', - }), - ).toBe(false); + + describe('aspect_ratio', () => { + it('succeeds when 1:1', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:image:aspect_ratio': '1:1', + }), + ).toBe(true); + }); + it('succeeds when 1.91:1', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:image:aspect_ratio': '1.91:1', + }), + ).toBe(true); + }); + it('fails when some other value', () => { + expect( + vNextSchema.isValidSync({ + ...baseGoodDefinition, + 'fc:frame:image:aspect_ratio': '1.618:1', + }), + ).toBe(false); + }); }); }); }); diff --git a/framegear/utils/validation.ts b/framegear/utils/validation.ts index 52b5b3d653..a92d0dd43a 100644 --- a/framegear/utils/validation.ts +++ b/framegear/utils/validation.ts @@ -36,7 +36,7 @@ export const vNextSchema = yup.object({ .optional() .matches( /^post$|^post_redirect$|^mint$|^link$/, - `button action must be "post" or "post_url". Failed on index: ${index}`, + `button action must be "post" or "post_redirect". Failed on index: ${index}`, ), }), {}, @@ -73,6 +73,13 @@ export const vNextSchema = yup.object({ .string() .optional() .matches(/^1:1$|^1.91:1$/), + 'fc:frame:state': yup + .string() + .optional() + .test('state-has-valid-size', 'frame:state has maximum size of 4096 bytes', (value) => { + // test only fires when `value` is defined + return new Blob([value!]).size <= 4096; + }), }); // This interface doesn't fully encapsulate the dynamically defined types. Do we even need it? diff --git a/framegear/yarn.lock b/framegear/yarn.lock index 8aacfced27..c9d3e0a7d9 100644 --- a/framegear/yarn.lock +++ b/framegear/yarn.lock @@ -438,17 +438,18 @@ __metadata: languageName: node linkType: hard -"@coinbase/onchainkit@npm:0.9.4": - version: 0.9.4 - resolution: "@coinbase/onchainkit@npm:0.9.4" +"@coinbase/onchainkit@npm:0.10.0": + version: 0.10.0 + resolution: "@coinbase/onchainkit@npm:0.10.0" peerDependencies: + "@tanstack/react-query": ^5 "@xmtp/frames-validator": ^0.5.0 graphql: ^14 graphql-request: ^6 react: ^18 react-dom: ^18 viem: ^2.7.0 - checksum: 0e7ea8c3eda0d46f3eb690dc04f02db13fd0304b89150961f4047b33ef218182a2e04b4306889647becc679bdc681e2bc5cf0f604f968bd5af97328241305398 + checksum: f1b3bb3d805703c3fcb5e28821971efbaa098cd7729995ac8a8502cdec38395cbcbcac81442f8ce45a30c333ecafaaeaa368fce77ca69732f045c88ec93342e8 languageName: node linkType: hard @@ -3193,7 +3194,7 @@ __metadata: version: 0.0.0-use.local resolution: "framegear@workspace:." dependencies: - "@coinbase/onchainkit": "npm:0.9.4" + "@coinbase/onchainkit": "npm:0.10.0" "@radix-ui/react-icons": "npm:^1.3.0" "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^14.2.1"