diff --git a/packages/snack-content/jest.config.js b/packages/snack-content/jest.config.js index ea6aed12..a2c9510e 100644 --- a/packages/snack-content/jest.config.js +++ b/packages/snack-content/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: 'ts-jest', rootDir: 'src', + prettierPath: require.resolve('jest-prettier'), }; diff --git a/packages/snack-content/package.json b/packages/snack-content/package.json index bb5623b7..b95604ca 100644 --- a/packages/snack-content/package.json +++ b/packages/snack-content/package.json @@ -38,6 +38,7 @@ "eslint": "^8.49.0", "eslint-config-universe": "^12.0.0", "jest": "^27.5.1", + "jest-prettier": "npm:prettier@^2.8.8", "prettier": "^3.0.3", "rimraf": "^3.0.2", "ts-jest": "~27.1.3", diff --git a/packages/snack-content/src/__tests__/urls-test.ts b/packages/snack-content/src/__tests__/urls-test.ts new file mode 100644 index 00000000..45336a33 --- /dev/null +++ b/packages/snack-content/src/__tests__/urls-test.ts @@ -0,0 +1,227 @@ +import { + createSnackRuntimeUrl, + parseSnackRuntimeUrl, + createEASUpdateSnackRuntimeUrl, + createClassicUpdateSnackRuntimeUrl, + parseEASUpdateSnackRuntimeUrl, + parseClassicUpdateSnackRuntimeUrl, +} from '../urls'; + +describe(createSnackRuntimeUrl, () => { + const channel = 'xy!z1_'; + + it('creates classic updates url with "channel" and "sdkVersion"', () => { + expect(createSnackRuntimeUrl({ channel, sdkVersion: '49.0.1' })).toMatchInlineSnapshot( + `"exp://exp.host/@snack/sdk.49.0.0-xy!z1_"`, + ); + }); + + it('creates eas update url with "channel" and "sdkVersion" >= 50', () => { + expect(createSnackRuntimeUrl({ channel, sdkVersion: '50.0.0' })).toMatchInlineSnapshot( + `"exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=xy%21z1_&runtime-version=exposdk%3A50.0.0"`, + ); + }); +}); + +describe(parseSnackRuntimeUrl, () => { + it('parses classic updates url with "channel" and "sdkVersion"', () => { + expect(parseSnackRuntimeUrl('exp://exp.host/@snack/sdk.49.0.0-xy!z1_')).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + "sdkVersion": "49.0.0", + } + `); + }); + + it('parses eas update url with "channel" and "sdkVersion"', () => { + expect( + parseSnackRuntimeUrl( + 'exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=xy%21z1_&runtime-version=exposdk%3A50.0.0', + ), + ).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + "sdkVersion": "50.0.0", + } + `); + }); +}); + +describe(createEASUpdateSnackRuntimeUrl, () => { + const channel = 'xy!z1_'; + const snack = 'JxS_FUOcGz'; + + it('creates url with "channel"', () => { + expect(createEASUpdateSnackRuntimeUrl({ channel })).toMatchInlineSnapshot( + '"exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=xy%21z1_"', + ); + }); + + it('creates url with "channel" and "sdkVersion"', () => { + expect(createEASUpdateSnackRuntimeUrl({ channel, sdkVersion: '50.0.1' })).toMatchInlineSnapshot( + '"exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=xy%21z1_&runtime-version=exposdk%3A50.0.0"', + ); + }); + + it('creates url with "channel, "sdkVersion", and "snack"', () => { + expect( + createEASUpdateSnackRuntimeUrl({ channel, snack, sdkVersion: 50 }), + ).toMatchInlineSnapshot( + '"exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack=JxS_FUOcGz&snack-channel=xy%21z1_&runtime-version=exposdk%3A50.0.0"', + ); + }); +}); + +describe(parseEASUpdateSnackRuntimeUrl, () => { + const channel = 'xy%21z1_'; + const snack = 'JxS_FUOcGz'; + const sdkVersion = 'exposdk%3A50.0.0'; + + it('parses url without any parameters', () => { + expect( + parseEASUpdateSnackRuntimeUrl('exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824'), + ).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses url with "channel"', () => { + expect( + parseEASUpdateSnackRuntimeUrl( + `exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=${channel}`, + ), + ).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + } + `); + }); + + it('parses url with "channel" and "sdkVersion"', () => { + expect( + parseEASUpdateSnackRuntimeUrl( + `exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=${channel}&runtime-version=${sdkVersion}`, + ), + ).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + "sdkVersion": "50.0.0", + } + `); + }); + + it('parses url with "channel", "sdkVersion", and "snack"', () => { + expect( + parseEASUpdateSnackRuntimeUrl( + `exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824?snack-channel=${channel}&runtime-version=${sdkVersion}&snack=${snack}`, + ), + ).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + "sdkVersion": "50.0.0", + "snack": "JxS_FUOcGz", + } + `); + }); + + it('parses redirected url with "channel", "sdkVersion", and "snack"', () => { + expect( + parseEASUpdateSnackRuntimeUrl( + `exp://snack.expo.app?snack-channel=${channel}&runtime-version=${sdkVersion}&snack=${snack}`, + ), + ).toMatchInlineSnapshot(` + Object { + "channel": "xy!z1_", + "sdkVersion": "50.0.0", + "snack": "JxS_FUOcGz", + } + `); + }); +}); + +describe(createClassicUpdateSnackRuntimeUrl, () => { + const channel = 'qWeqG1!'; + + it('creates url using format "exp://exp.host/@snack/sdk.-"', () => { + expect( + createClassicUpdateSnackRuntimeUrl({ channel, sdkVersion: '49.0.9' }), + ).toMatchInlineSnapshot('"exp://exp.host/@snack/sdk.49.0.0-qWeqG1!"'); + }); + + it('creates url using format "exp://exp.host/@snack/+"', () => { + expect( + createClassicUpdateSnackRuntimeUrl({ snack: 'snack-name', sdkVersion: '48.0.0' }), + ).toMatchInlineSnapshot('"exp://exp.host/@snack/snack-name"'); + + expect( + createClassicUpdateSnackRuntimeUrl({ channel, snack: 'snack-name', sdkVersion: '47.0.0' }), + ).toMatchInlineSnapshot('"exp://exp.host/@snack/snack-name+qWeqG1!"'); + }); + + it('creates url using format "exp://exp.host/@/+"', () => { + expect( + createClassicUpdateSnackRuntimeUrl({ + snack: '@owner-name/snack-name', + sdkVersion: '48.0.0', + }), + ).toMatchInlineSnapshot('"exp://exp.host/@owner-name/snack-name"'); + + expect( + createClassicUpdateSnackRuntimeUrl({ + channel, + snack: '@owner-name/snack-name', + sdkVersion: '48.0.0', + }), + ).toMatchInlineSnapshot('"exp://exp.host/@owner-name/snack-name+qWeqG1!"'); + }); + + it('throws for format "exp://exp.host/@snack/sdk.-" without "sdkVersion"', () => { + expect(() => createClassicUpdateSnackRuntimeUrl({ channel })).toThrowError( + 'Cannot create classic updates URL with only "channel", "sdkVersion" is required', + ); + }); + + it('throws without "snack" or "channel"', () => { + expect(() => createClassicUpdateSnackRuntimeUrl({})).toThrowError( + 'Cannot create classic updates URL without "channel" or "snack"', + ); + }); +}); + +describe(parseClassicUpdateSnackRuntimeUrl, () => { + it('parses url without any paramters', () => { + expect(parseClassicUpdateSnackRuntimeUrl('exp://exp.host')).toMatchInlineSnapshot(`Object {}`); + }); + + it('parses url using format "exp://exp.host/@snack/sdk.-"', () => { + expect(parseClassicUpdateSnackRuntimeUrl('exp://exp.host/@snack/sdk.49.0.0-qWeqG1!')) + .toMatchInlineSnapshot(` + Object { + "channel": "qWeqG1!", + "sdkVersion": "49.0.0", + } + `); + }); + + it('parses url using format "exp://exp.host/@snack/+"', () => { + // We ignore these formats, as we can't determine the Snack without owner + expect( + parseClassicUpdateSnackRuntimeUrl('exp://exp.host/@snack/snack-name'), + ).toMatchInlineSnapshot(`Object {}`); + + expect(parseClassicUpdateSnackRuntimeUrl('exp://exp.host/@snack/snack-name+qWeqG1!')) + .toMatchInlineSnapshot(` + Object { + "channel": "qWeqG1!", + } + `); + }); + + it('parses url using format "exp://exp.host/@/+"', () => { + expect(parseClassicUpdateSnackRuntimeUrl('exp://exp.host/@owner-name/snack-name+qWeqG1!')) + .toMatchInlineSnapshot(` + Object { + "channel": "qWeqG1!", + "snack": "@owner-name/snack-name", + } + `); + }); +}); diff --git a/packages/snack-content/src/index.ts b/packages/snack-content/src/index.ts index 8791654b..b6aa37ff 100644 --- a/packages/snack-content/src/index.ts +++ b/packages/snack-content/src/index.ts @@ -2,3 +2,4 @@ export * from './defaults'; export * from './sdk'; export { default as sdks } from './sdks'; export * from './types'; +export * from './urls'; diff --git a/packages/snack-content/src/urls.ts b/packages/snack-content/src/urls.ts new file mode 100644 index 00000000..28246b79 --- /dev/null +++ b/packages/snack-content/src/urls.ts @@ -0,0 +1,168 @@ +type SnackRuntimeInfo = { + /** The unique Snack hash, referring to a saved Snack */ + snack?: string; + /** The Snack session, or messaging channel, to use. This is used for communication through the Snack Website */ + channel?: string; + /** The Expo SDK (semver) version or major version for the runtime. */ + sdkVersion?: string | number; +}; + +/** + * Create a URL that can be used to launch the Snack Runtime. + * This supports both classic updates as well as the new EAS Update format. + */ +export function createSnackRuntimeUrl(info: SnackRuntimeInfo) { + return info.sdkVersion && getMajorVersion(info.sdkVersion) >= 50 + ? createEASUpdateSnackRuntimeUrl(info) + : createClassicUpdateSnackRuntimeUrl(info); +} + +/** + * Parse the Snack information from Snack Runtme URL. + * This supports both classic updates as well as the new EAS Update format. + */ +export function parseSnackRuntimeUrl(url: string): SnackRuntimeInfo { + const classic = parseClassicUpdateSnackRuntimeUrl(url); + return Object.keys(classic).length ? classic : parseEASUpdateSnackRuntimeUrl(url); +} + +/** + * Get the major version of an exact semver version. + * This will throw an error if the major version can't be resolved. + */ +function getMajorVersion(semver: string | number): number { + if (typeof semver === 'number') { + return semver; + } + + const majorVersion = parseInt(semver.split('.')[0], 10); + if (!Number.isNaN(majorVersion)) { + return majorVersion; + } + + throw new Error(`Cannot resolve the major version from provided "sdkVersion": ${semver}`); +} + +/** + * Parse the URL string into a URL object. + * This sanitizes and removed non-standard protocols, and defaults to `http:`. + */ +function parseUrl(url: string) { + return new URL(url.replace(/^[a-z]+:/i, 'http:')); +} + +/** + * Create the EAS Update URL for the Snack Runtime. + * This URL points directly to the EAS Update server, + * and uses query paramters to pass the Snack information. + * - `snack` → The unique Snack hash, referring to a saved Snack. + * - `snack-channel` → The Snack session or messaging channel, used to connect to the Snack Website. + * - `runtime-version` → The EAS Update query parameter, referring to the compatible SDK version. + * + * Note, this URL always points to `exp://` + */ +export function createEASUpdateSnackRuntimeUrl(info: SnackRuntimeInfo): string { + // The `@exponent/snack` or Snack Runtime EAS Update endpoint + const url = new URL('https://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824'); + const { snack, channel, sdkVersion } = info; + + if (snack) url.searchParams.set('snack', snack); + if (channel) url.searchParams.set('snack-channel', channel); + if (sdkVersion) { + url.searchParams.set('runtime-version', `exposdk:${getMajorVersion(sdkVersion)}.0.0`); + } + + return url.toString().replace(/^https:/, 'exp:'); +} + +/** + * Parse the EAS Update URL from the Snack Runtime. + */ +export function parseEASUpdateSnackRuntimeUrl(url: string): SnackRuntimeInfo { + const { searchParams } = parseUrl(url); + const info: SnackRuntimeInfo = {}; + + if (searchParams.has('snack')) { + info.snack = searchParams.get('snack') ?? undefined; + } + + if (searchParams.has('snack-channel')) { + info.channel = searchParams.get('snack-channel') ?? undefined; + } + + if (searchParams.has('runtime-version')) { + info.sdkVersion = searchParams.get('runtime-version')!.replace(/^exposdk:/i, ''); + } + + return info; +} + +/** + * Create the classic updates URL for the Snack Runtime. + * These URLs follow these patterns: + * - exp://exp.host/@snack/sdk.- + * - exp://exp.host/@snack/+ + * > Note, this is technically not correct and won't be handled. But, it is what the old system did. + * - exp://exp.host/@/+ + * + * @deprecated This classic updates URL format is being phased out + */ +export function createClassicUpdateSnackRuntimeUrl(info: SnackRuntimeInfo): string { + const { snack, channel, sdkVersion } = info; + + if (channel) { + if (snack && snack.startsWith('@')) { + return `exp://exp.host/${snack}+${channel}`; + } else if (snack) { + return `exp://exp.host/@snack/${snack}+${channel}`; + } + + if (!sdkVersion) { + throw new Error( + 'Cannot create classic updates URL with only "channel", "sdkVersion" is required', + ); + } + + return `exp://exp.host/@snack/sdk.${getMajorVersion(sdkVersion)}.0.0-${channel}`; + } + + if (snack) { + return `exp://exp.host/${snack.match(/.*\/.*/) ? snack : `@snack/${snack}`}`; + } + + throw new Error('Cannot create classic updates URL without "channel" or "snack"'); +} + +/** + * The pattern to parse classic update URL's `pathname` into usable parts. + * @see https://regex101.com/r/SXN22B/1 + * @deprecated This classic updates URL format is being phased out + */ +const LEGACY_PATHNAME_PATTERN = /^\/(@[^/]+)\/(?:sdk.([0-9.]+)-|([^/+]+)\+)(.*)?$/i; + +/** + * Parse the classic updates URL from the Snack Runtime. + * These URLs follow these patterns: + * - exp://exp.host/@/+ + * - exp://exp.host/@snack/+ + * > Note, in this case, we ignore `` as we can't determine the owner. + * - exp://exp.host/@snack/sdk.- + * + * @deprecated This classic updates URL format is being phased out + */ +export function parseClassicUpdateSnackRuntimeUrl(url: string): SnackRuntimeInfo { + const { pathname } = parseUrl(url); + const [, owner, sdkVersion, name, channel] = pathname.match(LEGACY_PATHNAME_PATTERN) ?? []; + + // exp://exp.host/@snack/sdk.- + if (sdkVersion) return { sdkVersion, channel }; + + // exp://exp.host/@snack/+ + if (owner === '@snack') return { channel }; + + // exp://exp.host/@/+ + if (owner && name) return { snack: `${owner}/${name}`, channel }; + + // Default to no-info + return {}; +} diff --git a/packages/snack-sdk/src/index.ts b/packages/snack-sdk/src/index.ts index 5a61a944..73d14789 100644 --- a/packages/snack-sdk/src/index.ts +++ b/packages/snack-sdk/src/index.ts @@ -17,6 +17,12 @@ import { isFeatureSupported, standardizeDependencies, getDeprecatedModule, + createSnackRuntimeUrl, + parseSnackRuntimeUrl, + createEASUpdateSnackRuntimeUrl, + createClassicUpdateSnackRuntimeUrl, + parseEASUpdateSnackRuntimeUrl, + parseClassicUpdateSnackRuntimeUrl, } from 'snack-content'; import Snack, { @@ -56,4 +62,10 @@ export { getDeprecatedModule, defaultConfig, Snack, + createSnackRuntimeUrl, + parseSnackRuntimeUrl, + createEASUpdateSnackRuntimeUrl, + createClassicUpdateSnackRuntimeUrl, + parseEASUpdateSnackRuntimeUrl, + parseClassicUpdateSnackRuntimeUrl, }; diff --git a/packages/snack-sdk/src/utils.ts b/packages/snack-sdk/src/utils.ts index 8567914e..823da434 100644 --- a/packages/snack-sdk/src/utils.ts +++ b/packages/snack-sdk/src/utils.ts @@ -1,6 +1,6 @@ import fetchPonyfill from 'fetch-ponyfill'; import { customAlphabet } from 'nanoid'; -import { SDKVersion } from 'snack-content'; +import { SDKVersion, createSnackRuntimeUrl } from 'snack-content'; import { SnackError, SnackUser } from './types'; @@ -28,17 +28,10 @@ export function createChannel(channel?: string): string { } export function createURL(host: string, sdkVersion: SDKVersion, channel?: string, id?: string) { - if (channel) { - return id - ? id.startsWith('@') - ? `exp://${host}/${id}+${channel}` - : `exp://${host}/@snack/${id}+${channel}` - : `exp://${host}/@snack/sdk.${sdkVersion}-${channel}`; - } else if (id) { - return `exp://${host}/${id.match(/.*\/.*/) ? id : `@snack/${id}`}`; - } else { - return ''; - } + const url = createSnackRuntimeUrl({ channel, sdkVersion, snack: id }); + + // This is only supported for classic update urls + return url.replace('exp.host/', `${host}/`); } export function createError(config: { diff --git a/runtime/package.json b/runtime/package.json index bc0f93b5..04012bb1 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -56,6 +56,7 @@ "react-native-view-shot": "3.7.0", "react-native-web": "~0.19.6", "snack-babel-standalone": "file:../packages/snack-babel-standalone", + "snack-content": "file:../packages/snack-content", "snack-require-context": "file:../packages/snack-require-context", "socket.io-client": "~4.5.4", "source-map": "0.6.1" diff --git a/runtime/src/App.tsx b/runtime/src/App.tsx index a34979e3..f024a4b0 100644 --- a/runtime/src/App.tsx +++ b/runtime/src/App.tsx @@ -14,6 +14,7 @@ import { EmitterSubscription, NativeEventSubscription, } from 'react-native'; +import { parseSnackRuntimeUrl } from 'snack-content'; import { createVirtualModulePath } from 'snack-require-context'; import { AppLoading } from './AppLoading'; @@ -32,7 +33,7 @@ import { captureRef as takeSnapshotAsync } from './NativeModules/ViewShot'; import getDeviceIdAsync from './NativeModules/getDeviceIdAsync'; import * as Profiling from './Profiling'; import UpdateIndicator from './UpdateIndicator'; -import { parseExperienceURL } from './UrlUtils'; +import { parseTestTransportFromUrl } from './UrlUtils'; const API_SERVER_URL_STAGING = 'https://staging.exp.host'; const API_SERVER_URL_PROD = 'https://exp.host'; @@ -80,7 +81,7 @@ export default class App extends React.Component { const deviceId = await getDeviceIdAsync(); // Initialize messaging transport - const testTransport = initialURL ? parseExperienceURL(initialURL)?.testTransport : null; + const testTransport = initialURL ? parseTestTransportFromUrl(initialURL) : null; Messaging.init(deviceId, testTransport); // Initialize various things @@ -228,9 +229,9 @@ export default class App extends React.Component { // Open Snack session at given `url`, throw if bad URL or couldn't connect. All we need to do is // subscribe to the associated messaging channel, everything else is triggered by messages. _openUrl = (url: string): boolean => { - const parsedUrl = parseExperienceURL(url); + const { channel } = parseSnackRuntimeUrl(url); - if (!parsedUrl) { + if (!channel) { Logger.warn( `Snack URL didn't have either the format 'https://exp.host/@snack/SAVE_UUID+CHANNEL_UUID' or 'https://exp.host/@snack/sdk.14.0.0-UUID'` ); @@ -241,8 +242,6 @@ export default class App extends React.Component { Logger.info('Opening URL', url); - const { channel } = parsedUrl; - this.setState({ channel, initialURL: url, diff --git a/runtime/src/UrlUtils.ts b/runtime/src/UrlUtils.ts index 1277ff09..80b88b47 100644 --- a/runtime/src/UrlUtils.ts +++ b/runtime/src/UrlUtils.ts @@ -1,22 +1,7 @@ -export function parseExperienceURL( - experienceURL: string -): { channel: string; testTransport: string | null } | null { - const matches = experienceURL.match(/(\+|\/sdk\..*-)([^?]*)\??(.*$)/); - if (!matches) { - return null; - } - const channel = matches[2]; - - let testTransport = null; - const queryItems = (matches[3] ?? '').split(/&/g); - for (const item of queryItems) { - if (item.startsWith('testTransport=')) { - testTransport = item.substring(14); - break; - } - } - return { - channel, - testTransport, - }; +/** + * Return the `testTransport` query parameter from a Snack URL, if provided. + */ +export function parseTestTransportFromUrl(snackUrl: string) { + const url = new URL(snackUrl.replace(/^[a-z]+:/i, 'http://')); + return url.searchParams.get('testTransport'); } diff --git a/runtime/src/__tests__/UrlUtils-test.ts b/runtime/src/__tests__/UrlUtils-test.ts index 8a42537c..63d7f378 100644 --- a/runtime/src/__tests__/UrlUtils-test.ts +++ b/runtime/src/__tests__/UrlUtils-test.ts @@ -1,41 +1,24 @@ -import { parseExperienceURL } from '../UrlUtils'; +import { parseTestTransportFromUrl } from '../UrlUtils'; -describe(parseExperienceURL, () => { - it('should parse snack url', () => { - const result = parseExperienceURL('exp://exp.host/@snack/sdk.47.0.0-4AQkc5pxqe'); - expect(result?.channel).toBe('4AQkc5pxqe'); - expect(result?.testTransport).toBe(null); +describe(parseTestTransportFromUrl, () => { + it('returns `null` without "testTransport"', () => { + expect(parseTestTransportFromUrl('exp://exp.host/@snack/sdk')).toBeNull(); }); - it('should parse snack url with testTransport', () => { - const result = parseExperienceURL( - 'exp://exp.host/@snack/sdk.47.0.0-4AQkc5pxqe?foo=foo&testTransport=snackpub&bar=bar' + it('returns test transport when defined as query paramter', () => { + expect(parseTestTransportFromUrl('exp://exp.host/@snack/sdk?testTransport=snackpub')).toBe( + 'snackpub' ); - expect(result?.channel).toBe('4AQkc5pxqe'); - expect(result?.testTransport).toBe('snackpub'); - }); - - it('should parse account snack full name url', () => { - const result = parseExperienceURL('exp://exp.host/@johndoe/the-snack+4AQkc5pxqe'); - expect(result?.channel).toBe('4AQkc5pxqe'); - expect(result?.testTransport).toBe(null); - }); - - it('should parse account snack full name url with testTransport', () => { - const result = parseExperienceURL( - 'exp://exp.host/@johndoe/the-snack+4AQkc5pxqe?foo=foo&testTransport=snackpub&bar=bar' - ); - expect(result?.channel).toBe('4AQkc5pxqe'); - expect(result?.testTransport).toBe('snackpub'); - }); - - it('should return null for account snack full name url without channel', () => { - const result = parseExperienceURL('exp://exp.host/@johndoe/the-snack'); - expect(result).toBe(null); - }); + expect( + parseTestTransportFromUrl( + 'exp://exp.host/@johndoe/the-snack+4AQkc5pxqe?testTransport=snackpub' + ) + ).toBe('snackpub'); - it('should return null for invalid url', () => { - const result = parseExperienceURL('exp://exp.host/'); - expect(result).toBe(null); + expect( + parseTestTransportFromUrl( + 'exp://u.expo.dev/933fd9c0-1666-11e7-afca-d980795c5824/?snack-channel=4AQkc5pxqe&testTransport=snackpub' + ) + ).toBe('snackpub'); }); }); diff --git a/runtime/yarn.lock b/runtime/yarn.lock index 662ecf63..a36189fa 100644 --- a/runtime/yarn.lock +++ b/runtime/yarn.lock @@ -12191,6 +12191,13 @@ semver@^7.3.2: dependencies: lru-cache "^6.0.0" +semver@^7.3.4, semver@^7.5.2, semver@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + semver@^7.3.5, semver@^7.3.7, semver@~7.3.2: version "7.3.7" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" @@ -12205,13 +12212,6 @@ semver@^7.3.8, semver@^7.5.1: dependencies: lru-cache "^6.0.0" -semver@^7.5.2, semver@^7.5.3: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -12450,6 +12450,11 @@ smart-buffer@^4.1.0: "snack-babel-standalone@file:../packages/snack-babel-standalone": version "3.0.1" +"snack-content@file:../packages/snack-content": + version "1.6.0" + dependencies: + semver "^7.3.4" + "snack-require-context@file:../packages/snack-require-context": version "0.1.0" diff --git a/website/src/client/components/App.tsx b/website/src/client/components/App.tsx index c6e4e37d..aaa33ba4 100644 --- a/website/src/client/components/App.tsx +++ b/website/src/client/components/App.tsx @@ -841,7 +841,11 @@ class Main extends React.Component { let experienceURL = this.state.session.url; if (this.props.query.testTransport) { - experienceURL += `?testTransport=${this.props.query.testTransport}`; + if (experienceURL.includes('?')) { + experienceURL += `&testTransport=${this.props.query.testTransport}`; + } else { + experienceURL += `?testTransport=${this.props.query.testTransport}`; + } } if (this.state.isPreview) { diff --git a/yarn.lock b/yarn.lock index 18cb20a3..733456cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10679,6 +10679,11 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== +"jest-prettier@npm:prettier@^2.8.8": + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + jest-regex-util@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28"