<commit_before>/**
 * GA4 useExistingTagEffect hook tests.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Internal dependencies
 */
import { MODULES_ANALYTICS } from '../datastore/constants';
import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants';
import {
	CORE_SITE,
	AMP_MODE_SECONDARY,
} from '../../../googlesitekit/datastore/site/constants';
import { renderHook, actHook as act } from '../../../../../tests/js/test-utils';
import {
	createTestRegistry,
	untilResolved,
} from '../../../../../tests/js/utils';
import { createBuildAndReceivers } from '../../tagmanager/datastore/__factories__/utils';
import { withActive } from '../../../googlesitekit/modules/datastore/__fixtures__';
import useExistingTagEffect from './useExistingTagEffect';

describe( 'useExistingTagEffect', () => {
	let registry;

	beforeEach( () => {
		registry = createTestRegistry();
		// Set settings to prevent fetch in resolver.
		registry.dispatch( MODULES_ANALYTICS ).receiveGetSettings( {} );
		// Set no existing tag.
		registry.dispatch( MODULES_ANALYTICS ).receiveGetExistingTag( null );
	} );

	it( 'should not select an existing tag if it is available and should not disable the "use snippet" setting if the existing tag does not match the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-987654321-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		act( () => {
			renderHook( () => useExistingTagEffect(), { registry } );
		} );

		expect(
			registry.select( MODULES_ANALYTICS ).getAccountID()
		).toBeUndefined();
		expect(
			registry.select( MODULES_ANALYTICS ).getPropertyID()
		).toBeUndefined();
		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();
	} );

	it( 'should disable the "use snippet" setting if the existing tag matches the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID the resolved state.
			registry.dispatch( MODULES_ANALYTICS ).setAccountID( '' );
			registry.dispatch( MODULES_ANALYTICS ).setPropertyID( '' );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getAccountID() ).toBe(
			''
		);

		expect( registry.select( MODULES_ANALYTICS ).getPropertyID() ).toBe(
			''
		);

		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);
	} );

	it( 'should enable the "use snippet" setting if the existing tag no longer matches the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		// Set the account and property ID to match the existing tag.
		registry
			.dispatch( MODULES_ANALYTICS )
			.setAccountID( existingTag.accountID );
		registry
			.dispatch( MODULES_ANALYTICS )
			.setPropertyID( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID the resolved state.
			registry.dispatch( MODULES_ANALYTICS ).setAccountID( '' );
			registry.dispatch( MODULES_ANALYTICS ).setPropertyID( '' );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getAccountID() ).toBe(
			''
		);

		expect( registry.select( MODULES_ANALYTICS ).getPropertyID() ).toBe(
			''
		);

		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to no longer match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-555555555-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);
	} );

	it( 'does not change the useSnippet value when there is already a property ID on page load (property ID is same as existing tag)', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			// Manually set the useSnippet value to true.
			registry.dispatch( MODULES_ANALYTICS ).setUseSnippet( true );
			rerender();
		} );
		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to no longer match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-555555555-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);
	} );

	it( 'does not change the useSnippet value when there is already a property ID on page load (property ID is not the same as existing tag)', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		const secondTag = {
			accountID: '65432',
			propertyID: 'UA-555555555-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID as a different one.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( secondTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( secondTag.propertyID );

			// Manually set the useSnippet value to false.
			registry.dispatch( MODULES_ANALYTICS ).setUseSnippet( false );
			rerender();
		} );
		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to another one.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-777777777-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to the initoally selected tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( secondTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);
	} );
} );
<commit_msg>Fix typo in comment<commit_after>/**
 * GA4 useExistingTagEffect hook tests.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Internal dependencies
 */
import { MODULES_ANALYTICS } from '../datastore/constants';
import { CORE_MODULES } from '../../../googlesitekit/modules/datastore/constants';
import {
	CORE_SITE,
	AMP_MODE_SECONDARY,
} from '../../../googlesitekit/datastore/site/constants';
import { renderHook, actHook as act } from '../../../../../tests/js/test-utils';
import {
	createTestRegistry,
	untilResolved,
} from '../../../../../tests/js/utils';
import { createBuildAndReceivers } from '../../tagmanager/datastore/__factories__/utils';
import { withActive } from '../../../googlesitekit/modules/datastore/__fixtures__';
import useExistingTagEffect from './useExistingTagEffect';

describe( 'useExistingTagEffect', () => {
	let registry;

	beforeEach( () => {
		registry = createTestRegistry();
		// Set settings to prevent fetch in resolver.
		registry.dispatch( MODULES_ANALYTICS ).receiveGetSettings( {} );
		// Set no existing tag.
		registry.dispatch( MODULES_ANALYTICS ).receiveGetExistingTag( null );
	} );

	it( 'should not select an existing tag if it is available and should not disable the "use snippet" setting if the existing tag does not match the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-987654321-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		act( () => {
			renderHook( () => useExistingTagEffect(), { registry } );
		} );

		expect(
			registry.select( MODULES_ANALYTICS ).getAccountID()
		).toBeUndefined();
		expect(
			registry.select( MODULES_ANALYTICS ).getPropertyID()
		).toBeUndefined();
		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();
	} );

	it( 'should disable the "use snippet" setting if the existing tag matches the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID the resolved state.
			registry.dispatch( MODULES_ANALYTICS ).setAccountID( '' );
			registry.dispatch( MODULES_ANALYTICS ).setPropertyID( '' );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getAccountID() ).toBe(
			''
		);

		expect( registry.select( MODULES_ANALYTICS ).getPropertyID() ).toBe(
			''
		);

		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);
	} );

	it( 'should enable the "use snippet" setting if the existing tag no longer matches the propertyID', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		// Set the account and property ID to match the existing tag.
		registry
			.dispatch( MODULES_ANALYTICS )
			.setAccountID( existingTag.accountID );
		registry
			.dispatch( MODULES_ANALYTICS )
			.setPropertyID( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID the resolved state.
			registry.dispatch( MODULES_ANALYTICS ).setAccountID( '' );
			registry.dispatch( MODULES_ANALYTICS ).setPropertyID( '' );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getAccountID() ).toBe(
			''
		);

		expect( registry.select( MODULES_ANALYTICS ).getPropertyID() ).toBe(
			''
		);

		expect(
			registry.select( MODULES_ANALYTICS ).getUseSnippet()
		).toBeUndefined();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to no longer match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-555555555-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);
	} );

	it( 'does not change the useSnippet value when there is already a property ID on page load (property ID is same as existing tag)', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID to match the existing tag.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( existingTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );

			// Manually set the useSnippet value to true.
			registry.dispatch( MODULES_ANALYTICS ).setUseSnippet( true );
			rerender();
		} );
		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to no longer match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-555555555-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);
	} );

	it( 'does not change the useSnippet value when there is already a property ID on page load (property ID is not the same as existing tag)', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/properties-profiles/,
			{ body: { properties: [] }, status: 200 }
		);
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/profiles/,
			{ body: [], status: 200 }
		);

		const existingTag = {
			accountID: '54321',
			propertyID: 'UA-123456789-1',
		};

		const gtmAnalytics = {
			accountID: '12345',
			webPropertyID: 'UA-123456789-1',
			ampPropertyID: 'UA-123456789-1',
		};

		const secondTag = {
			accountID: '65432',
			propertyID: 'UA-555555555-1',
		};

		registry
			.dispatch( CORE_MODULES )
			.receiveGetModules( withActive( 'tagmanager' ) );

		registry
			.dispatch( CORE_SITE )
			.receiveSiteInfo( { ampMode: AMP_MODE_SECONDARY } );

		registry
			.dispatch( MODULES_ANALYTICS )
			.receiveGetExistingTag( existingTag.propertyID );

		const { buildAndReceiveWebAndAMP } = createBuildAndReceivers(
			registry
		);
		buildAndReceiveWebAndAMP( gtmAnalytics );

		const { rerender } = renderHook( () => useExistingTagEffect(), {
			registry,
		} );

		await untilResolved( registry, MODULES_ANALYTICS ).getSettings();

		act( () => {
			// Set the account and property ID as a different one.
			registry
				.dispatch( MODULES_ANALYTICS )
				.setAccountID( secondTag.accountID );
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( secondTag.propertyID );

			// Manually set the useSnippet value to false.
			registry.dispatch( MODULES_ANALYTICS ).setUseSnippet( false );
			rerender();
		} );
		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to another one.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( 'UA-777777777-1' );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);

		// Set the property ID to match the existing tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( existingTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			false
		);

		// Set the property ID to the initially selected tag.
		act( () => {
			registry
				.dispatch( MODULES_ANALYTICS )
				.setPropertyID( secondTag.propertyID );
			rerender();
		} );

		expect( registry.select( MODULES_ANALYTICS ).getUseSnippet() ).toBe(
			true
		);
	} );
} );
<|endoftext|>


<commit_before>import aws from '@sullux/aws-sdk'
import assert from 'assert'
import http from 'http'
import { parse } from 'xo-remote-parser'

import RemoteHandlerAbstract from './abstract'

// endpoints https://docs.aws.amazon.com/general/latest/gr/s3.html

// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MIN_PART_SIZE = 1024 * 1024 * 5 // 5MB
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PARTS_COUNT = 10000
const MAX_OBJECT_SIZE = 1024 * 1024 * 1024 * 1024 * 5 // 5TB
const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the smallest fragment size that still allows a 5TB upload in 10000 fragments, about 524MB
export default class S3Handler extends RemoteHandlerAbstract {
  constructor(remote, _opts) {
    super(remote)
    const { host, path, username, password, protocol, region } = parse(remote.url)
    const params = {
      accessKeyId: username,
      apiVersion: '2006-03-01',
      endpoint: host,
      s3ForcePathStyle: true,
      secretAccessKey: password,
      signatureVersion: 'v4',
      httpOptions: {
        timeout: 600000,
      },
    }
    if (protocol === 'http') {
      params.httpOptions.agent = new http.Agent()
      params.sslEnabled = false
    }
    if (region !== undefined) {
      params.region = region
    }

    this._s3 = aws(params).s3

    const splitPath = path.split('/').filter(s => s.length)
    this._bucket = splitPath.shift()
    this._dir = splitPath.join('/')
  }

  get type() {
    return 's3'
  }

  _createParams(file) {
    return { Bucket: this._bucket, Key: this._dir + file }
  }

  async _outputStream(path, input, { validator }) {
    await this._s3.upload(
      {
        ...this._createParams(path),
        Body: input,
      },
      { partSize: IDEAL_FRAGMENT_SIZE, queueSize: 1 }
    )
    if (validator !== undefined) {
      try {
        await validator.call(this, path)
      } catch (error) {
        await this.unlink(path)
        throw error
      }
    }
  }

  async _writeFile(file, data, options) {
    return this._s3.putObject({ ...this._createParams(file), Body: data })
  }

  async _createReadStream(path, options) {
    if (!(await this._isFile(path))) {
      const error = new Error(`ENOENT: no such file '${path}'`)
      error.code = 'ENOENT'
      error.path = path
      throw error
    }

    // https://github.com/Sullux/aws-sdk/issues/11
    return this._s3.getObject.raw(this._createParams(path)).createReadStream()
  }

  async _unlink(file) {
    return this._s3.deleteObject(this._createParams(file))
  }

  async _list(dir) {
    function splitPath(path) {
      return path.split('/').filter(d => d.length)
    }

    const prefix = [this._dir, dir].join('/')
    const splitPrefix = splitPath(prefix)
    const result = await this._s3.listObjectsV2({
      Bucket: this._bucket,
      Prefix: splitPrefix.join('/'),
    })
    const uniq = new Set()
    for (const entry of result.Contents) {
      const line = splitPath(entry.Key)
      if (line.length > splitPrefix.length) {
        uniq.add(line[splitPrefix.length])
      }
    }
    return [...uniq]
  }

  _mkdir() {
    // nothing to do, directories do not exist, they are part of the files' path
  }

  async _rename(oldPath, newPath) {
    const size = await this._getSize(oldPath)
    const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
    const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
    try {
      const parts = []
      let start = 0
      while (start < size) {
        const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
        const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
        const upload = await this._s3.uploadPartCopy(partParams)
        parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
        start += MAX_PART_SIZE
      }
      await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
    } catch (e) {
      await this._s3.abortMultipartUpload(multipartParams)
      throw e
    }
    await this._s3.deleteObject(this._createParams(oldPath))
  }

  async _getSize(file) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const result = await this._s3.headObject(this._createParams(file))
    return +result.ContentLength
  }

  async _read(file, buffer, position = 0) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const params = this._createParams(file)
    params.Range = `bytes=${position}-${position + buffer.length - 1}`
    const result = await this._s3.getObject(params)
    result.Body.copy(buffer)
    return { bytesRead: result.Body.length, buffer }
  }

  async _rmdir(path) {
    const entries = await this._list(path)
    if (entries.length !== 0) {
      const error = new Error(`ENOTEMPTY: directory not empty, rmdir '${path}`)
      error.code = 'ENOTEMPTY'
      error.path = path
      throw error
    }

    // nothing to do, directories do not exist, they are part of the files' path
  }

  async _write(file, buffer, position) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const uploadParams = this._createParams(file)
    let fileSize
    try {
      fileSize = +(await this._s3.headObject(uploadParams)).ContentLength
    } catch (e) {
      if (e.code === 'NotFound') {
        fileSize = 0
      } else {
        throw e
      }
    }
    if (fileSize < MIN_PART_SIZE) {
      const resultBuffer = Buffer.alloc(Math.max(fileSize, position + buffer.length))
      const fileContent = fileSize !== 0 ? (await this._s3.getObject(uploadParams)).Body : Buffer.alloc(0)
      fileContent.copy(resultBuffer)
      buffer.copy(resultBuffer, position)
      await this._s3.putObject({ ...uploadParams, Body: resultBuffer })
      return { buffer, bytesWritten: buffer.length }
    } else {
      // using this trick: https://stackoverflow.com/a/38089437/72637
      // multipart fragments have a minimum size of 5Mo and a max of 5Go unless they are last
      // splitting the file in 3 parts: [prefix, edit, suffix]
      // if `prefix` is bigger than 5Mo, it will be sourced from uploadPartCopy()
      // otherwise otherwise it will be downloaded, concatenated to `edit`
      // `edit` will always be an upload part
      // `suffix` will always be sourced from uploadPartCopy()
      // Then everything will be sliced in 5Gb parts before getting uploaded
      const multipartParams = await this._s3.createMultipartUpload(uploadParams)
      const copyMultipartParams = {
        ...multipartParams,
        CopySource: `/${this._bucket}/${this._dir + file}`,
      }
      try {
        const parts = []
        const prefixSize = position
        let suffixOffset = prefixSize + buffer.length
        let suffixSize = Math.max(0, fileSize - suffixOffset)
        let hasSuffix = suffixSize > 0
        let editBuffer = buffer
        let editBufferOffset = position
        let partNumber = 1
        let prefixPosition = 0
        // use floor() so that last fragment is handled in the if bellow
        let fragmentsCount = Math.floor(prefixSize / MAX_PART_SIZE)
        const prefixFragmentSize = MAX_PART_SIZE
        let prefixLastFragmentSize = prefixSize - prefixFragmentSize * fragmentsCount
        if (prefixLastFragmentSize >= MIN_PART_SIZE) {
          // the last fragment of the prefix is smaller than MAX_PART_SIZE, but bigger than the minimum
          // so we can copy it too
          fragmentsCount++
          prefixLastFragmentSize = 0
        }
        for (let i = 0; i < fragmentsCount; i++) {
          const fragmentEnd = Math.min(prefixPosition + prefixFragmentSize, prefixSize)
          assert.strictEqual(fragmentEnd - prefixPosition <= MAX_PART_SIZE, true)
          const range = `bytes=${prefixPosition}-${fragmentEnd - 1}`
          const copyPrefixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: range }
          const part = await this._s3.uploadPartCopy(copyPrefixParams)
          parts.push({ ETag: part.CopyPartResult.ETag, PartNumber: copyPrefixParams.PartNumber })
          prefixPosition += prefixFragmentSize
        }
        if (prefixLastFragmentSize) {
          // grab everything from the prefix that was too small to be copied, download and merge to the edit buffer.
          const downloadParams = { ...uploadParams, Range: `bytes=${prefixPosition}-${prefixSize - 1}` }
          const prefixBuffer = prefixSize > 0 ? (await this._s3.getObject(downloadParams)).Body : Buffer.alloc(0)
          editBuffer = Buffer.concat([prefixBuffer, buffer])
          editBufferOffset -= prefixLastFragmentSize
        }
        if (hasSuffix && editBuffer.length < MIN_PART_SIZE) {
          // the edit fragment is too short and is not the last fragment
          // let's steal from the suffix fragment to reach the minimum size
          // the suffix might be too short and itself entirely absorbed in the edit fragment, making it the last one.
          const complementSize = Math.min(MIN_PART_SIZE - editBuffer.length, suffixSize)
          const complementOffset = editBufferOffset + editBuffer.length
          suffixOffset += complementSize
          suffixSize -= complementSize
          hasSuffix = suffixSize > 0
          const prefixRange = `bytes=${complementOffset}-${complementOffset + complementSize - 1}`
          const downloadParams = { ...uploadParams, Range: prefixRange }
          const complementBuffer = (await this._s3.getObject(downloadParams)).Body
          editBuffer = Buffer.concat([editBuffer, complementBuffer])
        }
        const editParams = { ...multipartParams, Body: editBuffer, PartNumber: partNumber++ }
        const editPart = await this._s3.uploadPart(editParams)
        parts.push({ ETag: editPart.ETag, PartNumber: editParams.PartNumber })
        if (hasSuffix) {
          // use ceil because the last fragment can be arbitrarily small.
          const suffixFragments = Math.ceil(suffixSize / MAX_PART_SIZE)
          let suffixFragmentOffset = suffixOffset
          for (let i = 0; i < suffixFragments; i++) {
            const fragmentEnd = suffixFragmentOffset + MAX_PART_SIZE
            assert.strictEqual(Math.min(fileSize, fragmentEnd) - suffixFragmentOffset <= MAX_PART_SIZE, true)
            const suffixRange = `bytes=${suffixFragmentOffset}-${Math.min(fileSize, fragmentEnd) - 1}`
            const copySuffixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: suffixRange }
            const suffixPart = (await this._s3.uploadPartCopy(copySuffixParams)).CopyPartResult
            parts.push({ ETag: suffixPart.ETag, PartNumber: copySuffixParams.PartNumber })
            suffixFragmentOffset = fragmentEnd
          }
        }
        await this._s3.completeMultipartUpload({
          ...multipartParams,
          MultipartUpload: { Parts: parts },
        })
      } catch (e) {
        await this._s3.abortMultipartUpload(multipartParams)
        throw e
      }
    }
  }

  async _openFile(path, flags) {
    return path
  }

  async _closeFile(fd) {}
}
<commit_msg>fix(fs/S3#_unlink): throw EISDIR if dir<commit_after>import aws from '@sullux/aws-sdk'
import assert from 'assert'
import http from 'http'
import { parse } from 'xo-remote-parser'

import RemoteHandlerAbstract from './abstract'

// endpoints https://docs.aws.amazon.com/general/latest/gr/s3.html

// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MIN_PART_SIZE = 1024 * 1024 * 5 // 5MB
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PARTS_COUNT = 10000
const MAX_OBJECT_SIZE = 1024 * 1024 * 1024 * 1024 * 5 // 5TB
const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the smallest fragment size that still allows a 5TB upload in 10000 fragments, about 524MB
export default class S3Handler extends RemoteHandlerAbstract {
  constructor(remote, _opts) {
    super(remote)
    const { host, path, username, password, protocol, region } = parse(remote.url)
    const params = {
      accessKeyId: username,
      apiVersion: '2006-03-01',
      endpoint: host,
      s3ForcePathStyle: true,
      secretAccessKey: password,
      signatureVersion: 'v4',
      httpOptions: {
        timeout: 600000,
      },
    }
    if (protocol === 'http') {
      params.httpOptions.agent = new http.Agent()
      params.sslEnabled = false
    }
    if (region !== undefined) {
      params.region = region
    }

    this._s3 = aws(params).s3

    const splitPath = path.split('/').filter(s => s.length)
    this._bucket = splitPath.shift()
    this._dir = splitPath.join('/')
  }

  get type() {
    return 's3'
  }

  _createParams(file) {
    return { Bucket: this._bucket, Key: this._dir + file }
  }

  async _isNotEmptyDir(path) {
    const result = await this._s3.listObjectsV2({
      Bucket: this._bucket,
      MaxKeys: 1,
      Prefix: this._dir + path + '/',
    })
    return result.Contents.length !== 0
  }

  async _outputStream(path, input, { validator }) {
    await this._s3.upload(
      {
        ...this._createParams(path),
        Body: input,
      },
      { partSize: IDEAL_FRAGMENT_SIZE, queueSize: 1 }
    )
    if (validator !== undefined) {
      try {
        await validator.call(this, path)
      } catch (error) {
        await this.unlink(path)
        throw error
      }
    }
  }

  async _writeFile(file, data, options) {
    return this._s3.putObject({ ...this._createParams(file), Body: data })
  }

  async _createReadStream(path, options) {
    if (!(await this._isFile(path))) {
      const error = new Error(`ENOENT: no such file '${path}'`)
      error.code = 'ENOENT'
      error.path = path
      throw error
    }

    // https://github.com/Sullux/aws-sdk/issues/11
    return this._s3.getObject.raw(this._createParams(path)).createReadStream()
  }

  async _unlink(path) {
    await this._s3.deleteObject(this._createParams(path))
    if (await this._isNotEmptyDir(path)) {
      const error = new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`)
      error.code = 'EISDIR'
      error.path = path
      throw error
    }
  }

  async _list(dir) {
    function splitPath(path) {
      return path.split('/').filter(d => d.length)
    }

    const prefix = [this._dir, dir].join('/')
    const splitPrefix = splitPath(prefix)
    const result = await this._s3.listObjectsV2({
      Bucket: this._bucket,
      Prefix: splitPrefix.join('/'),
    })
    const uniq = new Set()
    for (const entry of result.Contents) {
      const line = splitPath(entry.Key)
      if (line.length > splitPrefix.length) {
        uniq.add(line[splitPrefix.length])
      }
    }
    return [...uniq]
  }

  _mkdir() {
    // nothing to do, directories do not exist, they are part of the files' path
  }

  async _rename(oldPath, newPath) {
    const size = await this._getSize(oldPath)
    const multipartParams = await this._s3.createMultipartUpload({ ...this._createParams(newPath) })
    const param2 = { ...multipartParams, CopySource: `/${this._bucket}/${this._dir}${oldPath}` }
    try {
      const parts = []
      let start = 0
      while (start < size) {
        const range = `bytes=${start}-${Math.min(start + MAX_PART_SIZE, size) - 1}`
        const partParams = { ...param2, PartNumber: parts.length + 1, CopySourceRange: range }
        const upload = await this._s3.uploadPartCopy(partParams)
        parts.push({ ETag: upload.CopyPartResult.ETag, PartNumber: partParams.PartNumber })
        start += MAX_PART_SIZE
      }
      await this._s3.completeMultipartUpload({ ...multipartParams, MultipartUpload: { Parts: parts } })
    } catch (e) {
      await this._s3.abortMultipartUpload(multipartParams)
      throw e
    }
    await this._s3.deleteObject(this._createParams(oldPath))
  }

  async _getSize(file) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const result = await this._s3.headObject(this._createParams(file))
    return +result.ContentLength
  }

  async _read(file, buffer, position = 0) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const params = this._createParams(file)
    params.Range = `bytes=${position}-${position + buffer.length - 1}`
    const result = await this._s3.getObject(params)
    result.Body.copy(buffer)
    return { bytesRead: result.Body.length, buffer }
  }

  async _rmdir(path) {
    const entries = await this._list(path)
    if (entries.length !== 0) {
      const error = new Error(`ENOTEMPTY: directory not empty, rmdir '${path}`)
      error.code = 'ENOTEMPTY'
      error.path = path
      throw error
    }

    // nothing to do, directories do not exist, they are part of the files' path
  }

  async _write(file, buffer, position) {
    if (typeof file !== 'string') {
      file = file.fd
    }
    const uploadParams = this._createParams(file)
    let fileSize
    try {
      fileSize = +(await this._s3.headObject(uploadParams)).ContentLength
    } catch (e) {
      if (e.code === 'NotFound') {
        fileSize = 0
      } else {
        throw e
      }
    }
    if (fileSize < MIN_PART_SIZE) {
      const resultBuffer = Buffer.alloc(Math.max(fileSize, position + buffer.length))
      const fileContent = fileSize !== 0 ? (await this._s3.getObject(uploadParams)).Body : Buffer.alloc(0)
      fileContent.copy(resultBuffer)
      buffer.copy(resultBuffer, position)
      await this._s3.putObject({ ...uploadParams, Body: resultBuffer })
      return { buffer, bytesWritten: buffer.length }
    } else {
      // using this trick: https://stackoverflow.com/a/38089437/72637
      // multipart fragments have a minimum size of 5Mo and a max of 5Go unless they are last
      // splitting the file in 3 parts: [prefix, edit, suffix]
      // if `prefix` is bigger than 5Mo, it will be sourced from uploadPartCopy()
      // otherwise otherwise it will be downloaded, concatenated to `edit`
      // `edit` will always be an upload part
      // `suffix` will always be sourced from uploadPartCopy()
      // Then everything will be sliced in 5Gb parts before getting uploaded
      const multipartParams = await this._s3.createMultipartUpload(uploadParams)
      const copyMultipartParams = {
        ...multipartParams,
        CopySource: `/${this._bucket}/${this._dir + file}`,
      }
      try {
        const parts = []
        const prefixSize = position
        let suffixOffset = prefixSize + buffer.length
        let suffixSize = Math.max(0, fileSize - suffixOffset)
        let hasSuffix = suffixSize > 0
        let editBuffer = buffer
        let editBufferOffset = position
        let partNumber = 1
        let prefixPosition = 0
        // use floor() so that last fragment is handled in the if bellow
        let fragmentsCount = Math.floor(prefixSize / MAX_PART_SIZE)
        const prefixFragmentSize = MAX_PART_SIZE
        let prefixLastFragmentSize = prefixSize - prefixFragmentSize * fragmentsCount
        if (prefixLastFragmentSize >= MIN_PART_SIZE) {
          // the last fragment of the prefix is smaller than MAX_PART_SIZE, but bigger than the minimum
          // so we can copy it too
          fragmentsCount++
          prefixLastFragmentSize = 0
        }
        for (let i = 0; i < fragmentsCount; i++) {
          const fragmentEnd = Math.min(prefixPosition + prefixFragmentSize, prefixSize)
          assert.strictEqual(fragmentEnd - prefixPosition <= MAX_PART_SIZE, true)
          const range = `bytes=${prefixPosition}-${fragmentEnd - 1}`
          const copyPrefixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: range }
          const part = await this._s3.uploadPartCopy(copyPrefixParams)
          parts.push({ ETag: part.CopyPartResult.ETag, PartNumber: copyPrefixParams.PartNumber })
          prefixPosition += prefixFragmentSize
        }
        if (prefixLastFragmentSize) {
          // grab everything from the prefix that was too small to be copied, download and merge to the edit buffer.
          const downloadParams = { ...uploadParams, Range: `bytes=${prefixPosition}-${prefixSize - 1}` }
          const prefixBuffer = prefixSize > 0 ? (await this._s3.getObject(downloadParams)).Body : Buffer.alloc(0)
          editBuffer = Buffer.concat([prefixBuffer, buffer])
          editBufferOffset -= prefixLastFragmentSize
        }
        if (hasSuffix && editBuffer.length < MIN_PART_SIZE) {
          // the edit fragment is too short and is not the last fragment
          // let's steal from the suffix fragment to reach the minimum size
          // the suffix might be too short and itself entirely absorbed in the edit fragment, making it the last one.
          const complementSize = Math.min(MIN_PART_SIZE - editBuffer.length, suffixSize)
          const complementOffset = editBufferOffset + editBuffer.length
          suffixOffset += complementSize
          suffixSize -= complementSize
          hasSuffix = suffixSize > 0
          const prefixRange = `bytes=${complementOffset}-${complementOffset + complementSize - 1}`
          const downloadParams = { ...uploadParams, Range: prefixRange }
          const complementBuffer = (await this._s3.getObject(downloadParams)).Body
          editBuffer = Buffer.concat([editBuffer, complementBuffer])
        }
        const editParams = { ...multipartParams, Body: editBuffer, PartNumber: partNumber++ }
        const editPart = await this._s3.uploadPart(editParams)
        parts.push({ ETag: editPart.ETag, PartNumber: editParams.PartNumber })
        if (hasSuffix) {
          // use ceil because the last fragment can be arbitrarily small.
          const suffixFragments = Math.ceil(suffixSize / MAX_PART_SIZE)
          let suffixFragmentOffset = suffixOffset
          for (let i = 0; i < suffixFragments; i++) {
            const fragmentEnd = suffixFragmentOffset + MAX_PART_SIZE
            assert.strictEqual(Math.min(fileSize, fragmentEnd) - suffixFragmentOffset <= MAX_PART_SIZE, true)
            const suffixRange = `bytes=${suffixFragmentOffset}-${Math.min(fileSize, fragmentEnd) - 1}`
            const copySuffixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: suffixRange }
            const suffixPart = (await this._s3.uploadPartCopy(copySuffixParams)).CopyPartResult
            parts.push({ ETag: suffixPart.ETag, PartNumber: copySuffixParams.PartNumber })
            suffixFragmentOffset = fragmentEnd
          }
        }
        await this._s3.completeMultipartUpload({
          ...multipartParams,
          MultipartUpload: { Parts: parts },
        })
      } catch (e) {
        await this._s3.abortMultipartUpload(multipartParams)
        throw e
      }
    }
  }

  async _openFile(path, flags) {
    return path
  }

  async _closeFile(fd) {}
}
<|endoftext|>


<commit_before>// ==UserScript==
// @name        Swearify
// @description Adds a number of enhancements to your experience on AIM games.
// @namespace   kaffeinition@gmail.com
// @include     http://aimgames.forummotion.com/*
// @version     2.buggerit.0.5.shamers.1449084681.7
// @grant       none
// @icon        http://i.imgur.com/HlEs1B4.png
// @license     MIT License (Expat); opensource.org/licenses/MIT
// @require     https://raw.githubusercontent.com/RadLikeWhoa/Countable/master/Countable.js
// @require     https://raw.githubusercontent.com/HulaSamsquanch/aimgames/master/swearify/textUtils.js
// @require     https://raw.githubusercontent.com/arasatasaygin/is.js/master/is.js
// ==/UserScript==

window.addEventListener('load', function() { /* shit goes down in here */
  if (is.ie() || is.safari() || is.opera()) alert('This browser is unsupported by Swearify.');
  else {

  }
  //debugg(); // get info and shit
}, false);
<commit_msg>Update swearify.user.js<commit_after>// ==UserScript==
// @name        Swearify
// @description Adds a number of enhancements to your experience on AIM games.
// @namespace   kaffeinition@gmail.com
// @include     http://aimgames.forummotion.com/*
// @version     beta 0.trash.5
// @grant       none
// @icon        http://i.imgur.com/HlEs1B4.png
// @license     MIT License (Expat); opensource.org/licenses/MIT
// @require     https://raw.githubusercontent.com/RadLikeWhoa/Countable/master/Countable.js
// @require     https://raw.githubusercontent.com/HulaSamsquanch/aimgames/master/swearify/textUtils.js
// @require     https://raw.githubusercontent.com/arasatasaygin/is.js/master/is.js
// ==/UserScript==

window.addEventListener('load', function() { /* shit goes down in here */
  if (is.ie() || is.safari() || is.opera()) alert('This browser is unsupported by Swearify.');
  else {
    var chatFrame = document.getElementById('frame_chatbox').contentWindow.document.getElementsByTagName('script');
    var chatScript;
    for (var i = 0; i < chatFrame.length; i++)
    	if (chatFrame[i].src == 'http://illiweb.com/rsc/18/frm/chatbox/chatbox8.js')
    		chatScript = chatFrame[i];
    //remove old script since we can't edit src directly
    var chatScriptParent = chatScript.parentNode;
    chatScriptParent.removeChild(chatScript);
    //make a new script with the right src
    chatScript = document.createElement('script');
    chatScript.type = 'text/javascript';
    chatScript.src = 'https://cdn.rawgit.com/HulaSamsquanch/aimgames/hijack-cbox/swearify/hchat.js';
    chatScriptParent.appendChild(chatScript);
  }
  //debugg(); // get info and shit
}, false);
<|endoftext|>


<commit_before>var fs = require('fs'),
    jshint = require('jshint'),
    UglifyJS = require('uglify-js'),

    deps = require('./deps.js').deps,
    hintrc = require('./hintrc.js').config;

function lintFiles(files) {

	var errorsFound = 0,
	    i, j, len, len2, src, errors, e;

	for (i = 0, len = files.length; i < len; i++) {

		jshint.JSHINT(fs.readFileSync(files[i], 'utf8'), hintrc, i ? {L: true} : null);
		errors = jshint.JSHINT.errors;

		for (j = 0, len2 = errors.length; j < len2; j++) {
			e = errors[j];
			console.log(files[i] + '\tline ' + e.line + '\tcol ' + e.character + '\t ' + e.reason);
		}

		errorsFound += len2;
	}

	return errorsFound;
}

function getFiles(compsBase32) {
	var memo = {},
	    comps;

	if (compsBase32) {
		comps = parseInt(compsBase32, 32).toString(2).split('');
		console.log('Managing dependencies...');
	}

	function addFiles(srcs) {
		for (var j = 0, len = srcs.length; j < len; j++) {
			memo[srcs[j]] = true;
		}
	}

	for (var i in deps) {
		if (comps) {
			if (parseInt(comps.pop(), 2) === 1) {
				console.log('\t* ' + i);
				addFiles(deps[i].src);
			} else {
				console.log('\t  ' + i);
			}
		} else {
			addFiles(deps[i].src);
		}
	}

	var files = [];

	for (var src in memo) {
		files.push('src/' + src);
	}

	return files;
}

exports.getFiles = getFiles;

exports.lint = function () {

	var files = getFiles();

	console.log('Checking for JS errors...');

	var errorsFound = lintFiles(files);

	if (errorsFound > 0) {
		console.log(errorsFound + ' error(s) found.\n');
		fail();
	} else {
		console.log('\tCheck passed');
	}
};

function getSizeDelta(newContent, oldContent) {
	if (!oldContent) {
		return 'new';
	}
	var newLen = newContent.replace(/\r\n?/g, '\n').length,
		oldLen = oldContent.replace(/\r\n?/g, '\n').length,
		delta = newLen - oldLen;

	return (delta >= 0 ? '+' : '') + delta;
}

function loadSilently(path) {
	try {
		return fs.readFileSync(path, 'utf8');
	} catch (e) {
		return null;
	}
}

function combineFiles(files) {
	var content = '';
	for (var i = 0, len = files.length; i < len; i++) {
		content += fs.readFileSync(files[i], 'utf8') + '\n\n';
	}
	return content;
}

exports.build = function (compsBase32, buildName) {

	var files = getFiles(compsBase32);

	console.log('Concatenating ' + files.length + ' files...');

	var copy = fs.readFileSync('src/copyright.js', 'utf8'),
	    intro = '(function (window, document, undefined) {',
	    outro = '}(this, document));',
	    newSrc = copy + intro + combineFiles(files) + outro,

	    pathPart = 'dist/leaflet' + (buildName ? '-' + buildName : ''),
	    srcPath = pathPart + '-src.js',

	    oldSrc = loadSilently(srcPath),
	    srcDelta = getSizeDelta(newSrc, oldSrc);

	console.log('\tUncompressed size: ' + newSrc.length + ' bytes (' + srcDelta + ')');

	if (newSrc === oldSrc) {
		console.log('\tNo changes');
	} else {
		fs.writeFileSync(srcPath, newSrc);
		console.log('\tSaved to ' + srcPath);
	}

	console.log('Compressing...');

	var path = pathPart + '.js',
	    oldCompressed = loadSilently(path),
	    newCompressed = copy + UglifyJS.minify(newSrc, {
	        warnings: true,
	        fromString: true
	    }).code,
	    delta = getSizeDelta(newCompressed, oldCompressed);

	console.log('\tCompressed size: ' + newCompressed.length + ' bytes (' + delta + ')');

	if (newCompressed === oldCompressed) {
		console.log('\tNo changes');
	} else {
		fs.writeFileSync(path, newCompressed);
		console.log('\tSaved to ' + path);
	}
};

exports.test = function() {
	var karma = require('karma'),
	    testConfig = {configFile : __dirname + '/../spec/karma.conf.js'};

	testConfig.browsers = ['PhantomJS'];

	if (isArgv('--chrome')) {
		testConfig.browsers.push('Chrome');
	}
	if (isArgv('--ff')) {
		testConfig.browsers.push('Firefox');
	}

	if (isArgv('--cov')) {
		testConfig.preprocessors = {
			'**/src/**/*.js': 'coverage'
		};
		testConfig.coverageReporter = {
			type : 'html',
			dir : 'coverage/'
		};
		testConfig.reporters = ['coverage'];
	}

	karma.server.start(testConfig);

	function isArgv(optName) {
		return process.argv.indexOf(optName) !== -1;
	}
};
<commit_msg>fix coverage reporting in Karma #1479, close karma-runner/karma#461<commit_after>var fs = require('fs'),
    jshint = require('jshint'),
    UglifyJS = require('uglify-js'),

    deps = require('./deps.js').deps,
    hintrc = require('./hintrc.js').config;

function lintFiles(files) {

	var errorsFound = 0,
	    i, j, len, len2, src, errors, e;

	for (i = 0, len = files.length; i < len; i++) {

		jshint.JSHINT(fs.readFileSync(files[i], 'utf8'), hintrc, i ? {L: true} : null);
		errors = jshint.JSHINT.errors;

		for (j = 0, len2 = errors.length; j < len2; j++) {
			e = errors[j];
			console.log(files[i] + '\tline ' + e.line + '\tcol ' + e.character + '\t ' + e.reason);
		}

		errorsFound += len2;
	}

	return errorsFound;
}

function getFiles(compsBase32) {
	var memo = {},
	    comps;

	if (compsBase32) {
		comps = parseInt(compsBase32, 32).toString(2).split('');
		console.log('Managing dependencies...');
	}

	function addFiles(srcs) {
		for (var j = 0, len = srcs.length; j < len; j++) {
			memo[srcs[j]] = true;
		}
	}

	for (var i in deps) {
		if (comps) {
			if (parseInt(comps.pop(), 2) === 1) {
				console.log('\t* ' + i);
				addFiles(deps[i].src);
			} else {
				console.log('\t  ' + i);
			}
		} else {
			addFiles(deps[i].src);
		}
	}

	var files = [];

	for (var src in memo) {
		files.push('src/' + src);
	}

	return files;
}

exports.getFiles = getFiles;

exports.lint = function () {

	var files = getFiles();

	console.log('Checking for JS errors...');

	var errorsFound = lintFiles(files);

	if (errorsFound > 0) {
		console.log(errorsFound + ' error(s) found.\n');
		fail();
	} else {
		console.log('\tCheck passed');
	}
};

function getSizeDelta(newContent, oldContent) {
	if (!oldContent) {
		return 'new';
	}
	var newLen = newContent.replace(/\r\n?/g, '\n').length,
		oldLen = oldContent.replace(/\r\n?/g, '\n').length,
		delta = newLen - oldLen;

	return (delta >= 0 ? '+' : '') + delta;
}

function loadSilently(path) {
	try {
		return fs.readFileSync(path, 'utf8');
	} catch (e) {
		return null;
	}
}

function combineFiles(files) {
	var content = '';
	for (var i = 0, len = files.length; i < len; i++) {
		content += fs.readFileSync(files[i], 'utf8') + '\n\n';
	}
	return content;
}

exports.build = function (compsBase32, buildName) {

	var files = getFiles(compsBase32);

	console.log('Concatenating ' + files.length + ' files...');

	var copy = fs.readFileSync('src/copyright.js', 'utf8'),
	    intro = '(function (window, document, undefined) {',
	    outro = '}(this, document));',
	    newSrc = copy + intro + combineFiles(files) + outro,

	    pathPart = 'dist/leaflet' + (buildName ? '-' + buildName : ''),
	    srcPath = pathPart + '-src.js',

	    oldSrc = loadSilently(srcPath),
	    srcDelta = getSizeDelta(newSrc, oldSrc);

	console.log('\tUncompressed size: ' + newSrc.length + ' bytes (' + srcDelta + ')');

	if (newSrc === oldSrc) {
		console.log('\tNo changes');
	} else {
		fs.writeFileSync(srcPath, newSrc);
		console.log('\tSaved to ' + srcPath);
	}

	console.log('Compressing...');

	var path = pathPart + '.js',
	    oldCompressed = loadSilently(path),
	    newCompressed = copy + UglifyJS.minify(newSrc, {
	        warnings: true,
	        fromString: true
	    }).code,
	    delta = getSizeDelta(newCompressed, oldCompressed);

	console.log('\tCompressed size: ' + newCompressed.length + ' bytes (' + delta + ')');

	if (newCompressed === oldCompressed) {
		console.log('\tNo changes');
	} else {
		fs.writeFileSync(path, newCompressed);
		console.log('\tSaved to ' + path);
	}
};

exports.test = function() {
	var karma = require('karma'),
	    testConfig = {configFile : __dirname + '/../spec/karma.conf.js'};

	testConfig.browsers = ['PhantomJS'];

	if (isArgv('--chrome')) {
		testConfig.browsers.push('Chrome');
	}
	if (isArgv('--ff')) {
		testConfig.browsers.push('Firefox');
	}

	if (isArgv('--cov')) {
		testConfig.preprocessors = {
			'../src/**/*.js': 'coverage'
		};
		testConfig.coverageReporter = {
			type : 'html',
			dir : 'coverage/'
		};
		testConfig.reporters = ['coverage'];
	}

	karma.server.start(testConfig);

	function isArgv(optName) {
		return process.argv.indexOf(optName) !== -1;
	}
};
<|endoftext|>


<commit_before>/*!
 * jquery Tweet Parser v1.2.0
 * Parse an element containing a tweet and turn URLS, @user & #hashtags into urls
 * MIT License
 * by Vincent Loy
 * http://vincent-loy.fr
 */

/*global $, jQuery*/
(function ($) {
    "use strict";

    $.fn.tweetParser = function (options) {

        var defauts = {
                "urlClass": "tweet_link",
                "userClass": "tweet_user",
                "hashtagClass": "hashtag",
                "target": "_blank",
                "searchWithHashtags": true,
                "parseUsers" : true,
                "parseHashtags" : true,
                "parseUrls" : true
            },
            parametres = $.extend(defauts, options);

        return this.each(function () {

            //contain the tweet
            var tweet = $(this).html(),
                searchlink, //search link for hashtag
                link, //html  tag
                userOnly, //users on tweet
                hashtagOnly, //hashtags on tweet
                url, //url to hashtag search
            //regex
                regexUrl = /(https?|ftps?)(\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3})(\/\S*)?/g, //regex for urls
                regexUser = /\B@([a-zA-Z0-9_]+)/g, //regex for @users
                regexHashtag = /\B(#[á-úÁ-Úä-üÄ-Üa-zA-Z0-9_]+)/g; //regex for #hashtags

            //Hashtag Search link
            if (parametres.searchWithHashtags) {
                //this is the search with hashtag
                searchlink = "https://twitter.com/hashtag/";
            } else {
                //this is a more global search including hashtags and the word itself
                searchlink = "https://twitter.com/search?q=";
            }

            //turn URLS in the tweet into... working urls
            if (parametres.parseUrls) {
                tweet = tweet.replace(regexUrl, function (url) {
                    link = '' + url + '';
                    return url.replace(url, link);
                });
            }

            //turn @users in the tweet into... working urls
            if (parametres.parseUsers) {
                tweet = tweet.replace(regexUser, function (user) {
                    userOnly = user.slice(1);
                    link = '' + user + '';
                    return user.replace(user, link);
                });
            }

            //turn #hashtags in the tweet into... working urls
            if (parametres.parseHashtags) {
                tweet = tweet.replace(regexHashtag, function (hashtag) {
                    hashtagOnly = hashtag.slice(1);
                    url = searchlink + hashtagOnly;
                    link = '' + hashtag + '';
                    return hashtag.replace(hashtag, link);
                });
            }

            //then, it inject the last var into the element containing the tweet
            $(this).html(tweet);

            //add target attribute to all urls
            $(this).find("a").attr("target", parametres.target);

        });
    };
}(jQuery));<commit_msg>Add better URL regex from @Xowap : http://hyperthese.net/post/url-regular-expression/<commit_after>/*!
 * jquery Tweet Parser v1.2.0
 * Parse an element containing a tweet and turn URLS, @user & #hashtags into urls
 * MIT License
 * by Vincent Loy
 * http://vincent-loy.fr
 */

/*global $, jQuery*/
(function ($) {
    "use strict";

    $.fn.tweetParser = function (options) {

        var defauts = {
                "urlClass": "tweet_link",
                "userClass": "tweet_user",
                "hashtagClass": "hashtag",
                "target": "_blank",
                "searchWithHashtags": true,
                "parseUsers" : true,
                "parseHashtags" : true,
                "parseUrls" : true
            },
            parametres = $.extend(defauts, options);

        return this.each(function () {

            //contain the tweet
            var tweet = $(this).html(),
                searchlink, //search link for hashtag
                link, //html  tag
                userOnly, //users on tweet
                hashtagOnly, //hashtags on tweet
                url, //url to hashtag search
            //regex
                regexUrl = /(^|\s)((f|ht)tps?:\/\/([^ \t\r\n]*[^ \t\r\n\)*_,\.]))/g, //regex for urls
                regexUser = /\B@([a-zA-Z0-9_]+)/g, //regex for @users
                regexHashtag = /\B(#[á-úÁ-Úä-üÄ-Üa-zA-Z0-9_]+)/g; //regex for #hashtags

            //Hashtag Search link
            if (parametres.searchWithHashtags) {
                //this is the search with hashtag
                searchlink = "https://twitter.com/hashtag/";
            } else {
                //this is a more global search including hashtags and the word itself
                searchlink = "https://twitter.com/search?q=";
            }

            //turn URLS in the tweet into... working urls
            if (parametres.parseUrls) {
                tweet = tweet.replace(regexUrl, function (url) {
                    link = '' + url + '';
                    return url.replace(url, link);
                });
            }

            //turn @users in the tweet into... working urls
            if (parametres.parseUsers) {
                tweet = tweet.replace(regexUser, function (user) {
                    userOnly = user.slice(1);
                    link = '' + user + '';
                    return user.replace(user, link);
                });
            }

            //turn #hashtags in the tweet into... working urls
            if (parametres.parseHashtags) {
                tweet = tweet.replace(regexHashtag, function (hashtag) {
                    hashtagOnly = hashtag.slice(1);
                    url = searchlink + hashtagOnly;
                    link = '' + hashtag + '';
                    return hashtag.replace(hashtag, link);
                });
            }

            //then, it inject the last var into the element containing the tweet
            $(this).html(tweet);

            //add target attribute to all urls
            $(this).find("a").attr("target", parametres.target);

        });
    };
}(jQuery));<|endoftext|>


<commit_before>const {Node, TextNode} = require("./node")
const {Fragment} = require("./fragment")
const {Mark} = require("./mark")
const {ContentExpr} = require("./content")

const {copyObj} = require("../util/obj")
const {OrderedMap} = require("../util/orderedmap")

// For node types where all attrs have a default value (or which don't
// have any attributes), build up a single reusable default attribute
// object, and use it for all nodes that don't specify specific
// attributes.
function defaultAttrs(attrs) {
  let defaults = Object.create(null)
  for (let attrName in attrs) {
    let attr = attrs[attrName]
    if (attr.default === undefined) return null
    defaults[attrName] = attr.default
  }
  return defaults
}

function computeAttrs(attrs, value) {
  let built = Object.create(null)
  for (let name in attrs) {
    let given = value && value[name]
    if (given == null) {
      let attr = attrs[name]
      if (attr.default !== undefined)
        given = attr.default
      else if (attr.compute)
        given = attr.compute()
      else
        throw new RangeError("No value supplied for attribute " + name)
    }
    built[name] = given
  }
  return built
}

// ;; Node types are objects allocated once per `Schema`
// and used to tag `Node` instances with a type. They are
// instances of sub-types of this class, and contain information about
// the node type (its name, its allowed attributes, methods for
// serializing it to various formats, information to guide
// deserialization, and so on).
class NodeType {
  constructor(name, schema) {
    // :: string
    // The name the node type has in this schema.
    this.name = name
    // Freeze the attributes, to avoid calling a potentially expensive
    // getter all the time.
    Object.defineProperty(this, "attrs", {value: copyObj(this.attrs)})
    this.defaultAttrs = defaultAttrs(this.attrs)
    this.contentExpr = null
    // :: Schema
    // A link back to the `Schema` the node type belongs to.
    this.schema = schema
  }

  // :: bool
  // True if this is a block type.
  get isBlock() { return false }

  // :: bool
  // True if this is a textblock type, a block that contains inline
  // content.
  get isTextblock() { return false }

  // :: bool
  // True if this is an inline type.
  get isInline() { return false }

  // :: bool
  // True if this is the text node type.
  get isText() { return false }

  // :: bool
  // Controls whether nodes of this type can be selected (as a user
  // node selection).
  get selectable() { return true }

  // :: bool
  // Determines whether nodes of this type can be dragged. Enabling it
  // causes ProseMirror to set a `draggable` attribute on its DOM
  // representation, and to put its HTML serialization into the drag
  // event's [data
  // transfer](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer)
  // when dragged.
  get draggable() { return false }

  // :: bool
  // Controls whether this node type is locked.
  get locked() { return false }

  // :: bool
  // True for node types that allow no content.
  get isLeaf() { return this.contentExpr.isLeaf }

  hasRequiredAttrs(ignore) {
    for (let n in this.attrs)
      if (this.attrs[n].isRequired && (!ignore || !(n in ignore))) return true
    return false
  }

  compatibleContent(other) {
    return this == other || this.contentExpr.compatible(other.contentExpr)
  }

  computeAttrs(attrs) {
    if (!attrs && this.defaultAttrs) return this.defaultAttrs
    else return computeAttrs(this.attrs, attrs)
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Create a `Node` of this type. The given attributes are
  // checked and defaulted (you can pass `null` to use the type's
  // defaults entirely, if no required attributes exist). `content`
  // may be a `Fragment`, a node, an array of nodes, or
  // `null`. Similarly `marks` may be `null` to default to the empty
  // set of marks.
  create(attrs, content, marks) {
    return new Node(this, this.computeAttrs(attrs), Fragment.from(content), Mark.setFrom(marks))
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Like [`create`](NodeType.create), but check the given content
  // against the node type's content restrictions, and throw an error
  // if it doesn't match.
  createChecked(attrs, content, marks) {
    attrs = this.computeAttrs(attrs)
    content = Fragment.from(content)
    if (!this.validContent(content, attrs))
      throw new RangeError("Invalid content for node " + this.name)
    return new Node(this, attrs, content, Mark.setFrom(marks))
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → ?Node
  // Like [`create`](NodeType.create), but see if it is necessary to
  // add nodes to the start or end of the given fragment to make it
  // fit the node. If no fitting wrapping can be found, return null.
  // Note that, due to the fact that required nodes can always be
  // created, this will always succeed if you pass null or
  // `Fragment.empty` as content.
  createAndFill(attrs, content, marks) {
    attrs = this.computeAttrs(attrs)
    content = Fragment.from(content)
    if (content.size) {
      let before = this.contentExpr.start(attrs).fillBefore(content)
      if (!before) return null
      content = before.append(content)
    }
    let after = this.contentExpr.getMatchAt(attrs, content).fillBefore(Fragment.empty, true)
    if (!after) return null
    return new Node(this, attrs, content.append(after), Mark.setFrom(marks))
  }

  // :: (Fragment, ?Object) → bool
  // Returns true if the given fragment is valid content for this node
  // type with the given attributes.
  validContent(content, attrs) {
    return this.contentExpr.matches(attrs, content)
  }

  static compile(nodes, schema) {
    let result = Object.create(null)
    nodes.forEach((name, spec) => result[name] = new spec.type(name, schema))

    if (!result.doc) throw new RangeError("Every schema needs a 'doc' type")
    if (!result.text) throw new RangeError("Every schema needs a 'text' type")

    return result
  }
}
exports.NodeType = NodeType

// ;; Base type for block nodetypes.
class Block extends NodeType {
  get isBlock() { return true }
  get isTextblock() { return this.contentExpr.inlineContent }
}
exports.Block = Block

// ;; Base type for inline node types.
class Inline extends NodeType {
  get isInline() { return true }
}
exports.Inline = Inline

// ;; The text node type.
class Text extends Inline {
  get selectable() { return false }
  get isText() { return true }

  create(attrs, content, marks) {
    return new TextNode(this, this.computeAttrs(attrs), content, marks)
  }
  toDOM(node) { return node.text }
}
exports.Text = Text

// Attribute descriptors

// ;; Attributes are named values associated with nodes and marks.
// Each node type or mark type has a fixed set of attributes, which
// instances of this class are used to control. Attribute values must
// be JSON-serializable.
class Attribute {
  // :: (Object)
  // Create an attribute. `options` is an object containing the
  // settings for the attributes. The following settings are
  // supported:
  //
  // **`default`**`: ?any`
  //   : The default value for this attribute, to choose when no
  //     explicit value is provided.
  //
  // **`compute`**`: ?() → any`
  //   : A function that computes a default value for the attribute.
  //
  // Attributes that have no default or compute property must be
  // provided whenever a node or mark of a type that has them is
  // created.
  constructor(options = {}) {
    this.default = options.default
    this.compute = options.compute
  }

  get isRequired() {
    return this.default === undefined && !this.compute
  }
}
exports.Attribute = Attribute

// Marks

// ;; Like nodes, marks (which are associated with nodes to signify
// things like emphasis or being part of a link) are tagged with type
// objects, which are instantiated once per `Schema`.
class MarkType {
  constructor(name, rank, schema) {
    // :: string
    // The name of the mark type.
    this.name = name
    Object.defineProperty(this, "attrs", {value: copyObj(this.attrs)})
    this.rank = rank
    // :: Schema
    // The schema that this mark type instance is part of.
    this.schema = schema
    let defaults = defaultAttrs(this.attrs)
    this.instance = defaults && new Mark(this, defaults)
  }

  // :: bool
  // Whether this mark should be active when the cursor is positioned
  // at the end of the mark.
  get inclusiveRight() { return true }

  // :: (?Object) → Mark
  // Create a mark of this type. `attrs` may be `null` or an object
  // containing only some of the mark's attributes. The others, if
  // they have defaults, will be added.
  create(attrs) {
    if (!attrs && this.instance) return this.instance
    return new Mark(this, computeAttrs(this.attrs, attrs))
  }

  static compile(marks, schema) {
    let result = Object.create(null), rank = 0
    marks.forEach((name, markType) => result[name] = new markType(name, rank++, schema))
    return result
  }

  // :: ([Mark]) → [Mark]
  // When there is a mark of this type in the given set, a new set
  // without it is returned. Otherwise, the input set is returned.
  removeFromSet(set) {
    for (var i = 0; i < set.length; i++)
      if (set[i].type == this)
        return set.slice(0, i).concat(set.slice(i + 1))
    return set
  }

  // :: ([Mark]) → ?Mark
  // Tests whether there is a mark of this type in the given set.
  isInSet(set) {
    for (let i = 0; i < set.length; i++)
      if (set[i].type == this) return set[i]
  }
}
exports.MarkType = MarkType

// ;; #path=SchemaSpec #kind=interface
// An object describing a schema, as passed to the `Schema`
// constructor.

// :: union<Object<NodeSpec>, OrderedMap<NodeSpec>> #path=SchemaSpec.nodes
// The node types in this schema. Maps names to `NodeSpec` objects
// describing the node to be associated with that name. Their order is significant

// :: ?union<Object<constructor<MarkType>>, OrderedMap<constructor<MarkType>>> #path=SchemaSpec.marks
// The mark types that exist in this schema.

// ;; #path=NodeSpec #kind=interface

// :: constructor<NodeType> #path=NodeSpec.type
// The `NodeType` class to be used for this node.

// :: ?string #path=NodeSpec.content
// The content expression for this node, as parsed by
// `ContentExpr.parse`. When not given, the node does not allow any
// content.

// :: ?string #path=NodeSpec.group
// The group or space-separated groups to which this node belongs, as
// referred to in the content expressions for the schema.

// ;; Each document is based on a single schema, which provides the
// node and mark types that it is made up of (which, in turn,
// determine the structure it is allowed to have).
class Schema {
  // :: (SchemaSpec)
  // Construct a schema from a specification.
  constructor(spec) {
    // :: OrderedMap<NodeSpec> The node specs that the schema is based on.
    this.nodeSpec = OrderedMap.from(spec.nodes)
    // :: OrderedMap<constructor<MarkType>> The mark spec that the schema is based on.
    this.markSpec = OrderedMap.from(spec.marks)

    // :: Object<NodeType>
    // An object mapping the schema's node names to node type objects.
    this.nodes = NodeType.compile(this.nodeSpec, this)
    // :: Object<MarkType>
    // A map from mark names to mark type objects.
    this.marks = MarkType.compile(this.markSpec, this)
    for (let prop in this.nodes) {
      if (prop in this.marks)
        throw new RangeError(prop + " can not be both a node and a mark")
      let type = this.nodes[prop]
      type.contentExpr = ContentExpr.parse(type, this.nodeSpec.get(prop).content || "", this.nodeSpec)
    }

    // :: Object
    // An object for storing whatever values modules may want to
    // compute and cache per schema. (If you want to store something
    // in it, try to use property names unlikely to clash.)
    this.cached = Object.create(null)
    this.cached.wrappings = Object.create(null)

    this.node = this.node.bind(this)
    this.text = this.text.bind(this)
    this.nodeFromJSON = this.nodeFromJSON.bind(this)
    this.markFromJSON = this.markFromJSON.bind(this)
  }

  // :: (union<string, NodeType>, ?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Create a node in this schema. The `type` may be a string or a
  // `NodeType` instance. Attributes will be extended
  // with defaults, `content` may be a `Fragment`,
  // `null`, a `Node`, or an array of nodes.
  //
  // When creating a text node, `content` should be a string and is
  // interpreted as the node's text.
  //
  // This method is bound to the Schema, meaning you don't have to
  // call it as a method, but can pass it to higher-order functions
  // and such.
  node(type, attrs, content, marks) {
    if (typeof type == "string")
      type = this.nodeType(type)
    else if (!(type instanceof NodeType))
      throw new RangeError("Invalid node type: " + type)
    else if (type.schema != this)
      throw new RangeError("Node type from different schema used (" + type.name + ")")

    return type.createChecked(attrs, content, marks)
  }

  // :: (string, ?[Mark]) → Node
  // Create a text node in the schema. This method is bound to the
  // Schema. Empty text nodes are not allowed.
  text(text, marks) {
    return this.nodes.text.create(null, text, Mark.setFrom(marks))
  }

  // :: (string, ?Object) → Mark
  // Create a mark with the named type
  mark(name, attrs) {
    let spec = this.marks[name]
    if (!spec) throw new RangeError("No mark named " + name)
    return spec.create(attrs)
  }

  // :: (Object) → Node
  // Deserialize a node from its JSON representation. This method is
  // bound.
  nodeFromJSON(json) {
    return Node.fromJSON(this, json)
  }

  // :: (Object) → Mark
  // Deserialize a mark from its JSON representation. This method is
  // bound.
  markFromJSON(json) {
    let type = this.marks[json._]
    let attrs = null
    for (let prop in json) if (prop != "_") {
      if (!attrs) attrs = Object.create(null)
      attrs[prop] = json[prop]
    }
    return attrs ? type.create(attrs) : type.instance
  }

  // :: (string) → NodeType
  // Get the `NodeType` associated with the given name in
  // this schema, or raise an error if it does not exist.
  nodeType(name) {
    let found = this.nodes[name]
    if (!found) throw new RangeError("Unknown node type: " + name)
    return found
  }
}
exports.Schema = Schema
<commit_msg>Add the concept of ephemeral nodes<commit_after>const {Node, TextNode} = require("./node")
const {Fragment} = require("./fragment")
const {Mark} = require("./mark")
const {ContentExpr} = require("./content")

const {copyObj} = require("../util/obj")
const {OrderedMap} = require("../util/orderedmap")

// For node types where all attrs have a default value (or which don't
// have any attributes), build up a single reusable default attribute
// object, and use it for all nodes that don't specify specific
// attributes.
function defaultAttrs(attrs) {
  let defaults = Object.create(null)
  for (let attrName in attrs) {
    let attr = attrs[attrName]
    if (attr.default === undefined) return null
    defaults[attrName] = attr.default
  }
  return defaults
}

function computeAttrs(attrs, value) {
  let built = Object.create(null)
  for (let name in attrs) {
    let given = value && value[name]
    if (given == null) {
      let attr = attrs[name]
      if (attr.default !== undefined)
        given = attr.default
      else if (attr.compute)
        given = attr.compute()
      else
        throw new RangeError("No value supplied for attribute " + name)
    }
    built[name] = given
  }
  return built
}

// ;; Node types are objects allocated once per `Schema`
// and used to tag `Node` instances with a type. They are
// instances of sub-types of this class, and contain information about
// the node type (its name, its allowed attributes, methods for
// serializing it to various formats, information to guide
// deserialization, and so on).
class NodeType {
  constructor(name, schema) {
    // :: string
    // The name the node type has in this schema.
    this.name = name
    // Freeze the attributes, to avoid calling a potentially expensive
    // getter all the time.
    Object.defineProperty(this, "attrs", {value: copyObj(this.attrs)})
    this.defaultAttrs = defaultAttrs(this.attrs)
    this.contentExpr = null
    // :: Schema
    // A link back to the `Schema` the node type belongs to.
    this.schema = schema
  }

  // :: bool
  // True if this is a block type.
  get isBlock() { return false }

  // :: bool
  // True if this is a textblock type, a block that contains inline
  // content.
  get isTextblock() { return false }

  // :: bool
  // True if this is an inline type.
  get isInline() { return false }

  // :: bool
  // True if this is the text node type.
  get isText() { return false }

  // :: bool
  // Controls whether nodes of this type can be selected (as a user
  // node selection).
  get selectable() { return true }

  // :: bool
  // Determines whether nodes of this type can be dragged. Enabling it
  // causes ProseMirror to set a `draggable` attribute on its DOM
  // representation, and to put its HTML serialization into the drag
  // event's [data
  // transfer](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer)
  // when dragged.
  get draggable() { return false }

  // :: bool
  // Controls whether this node type is locked.
  get locked() { return false }

  // :: bool
  // True for node types that allow no content.
  get isLeaf() { return this.contentExpr.isLeaf }

  hasRequiredAttrs(ignore) {
    for (let n in this.attrs)
      if (this.attrs[n].isRequired && (!ignore || !(n in ignore))) return true
    return false
  }

  compatibleContent(other) {
    return this == other || this.contentExpr.compatible(other.contentExpr)
  }

  computeAttrs(attrs) {
    if (!attrs && this.defaultAttrs) return this.defaultAttrs
    else return computeAttrs(this.attrs, attrs)
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Create a `Node` of this type. The given attributes are
  // checked and defaulted (you can pass `null` to use the type's
  // defaults entirely, if no required attributes exist). `content`
  // may be a `Fragment`, a node, an array of nodes, or
  // `null`. Similarly `marks` may be `null` to default to the empty
  // set of marks.
  create(attrs, content, marks) {
    return new Node(this, this.computeAttrs(attrs), Fragment.from(content), Mark.setFrom(marks))
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Like [`create`](NodeType.create), but check the given content
  // against the node type's content restrictions, and throw an error
  // if it doesn't match.
  createChecked(attrs, content, marks) {
    attrs = this.computeAttrs(attrs)
    content = Fragment.from(content)
    if (!this.validContent(content, attrs))
      throw new RangeError("Invalid content for node " + this.name)
    return new Node(this, attrs, content, Mark.setFrom(marks))
  }

  // :: (?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → ?Node
  // Like [`create`](NodeType.create), but see if it is necessary to
  // add nodes to the start or end of the given fragment to make it
  // fit the node. If no fitting wrapping can be found, return null.
  // Note that, due to the fact that required nodes can always be
  // created, this will always succeed if you pass null or
  // `Fragment.empty` as content.
  createAndFill(attrs, content, marks) {
    attrs = this.computeAttrs(attrs)
    content = Fragment.from(content)
    if (content.size) {
      let before = this.contentExpr.start(attrs).fillBefore(content)
      if (!before) return null
      content = before.append(content)
    }
    let after = this.contentExpr.getMatchAt(attrs, content).fillBefore(Fragment.empty, true)
    if (!after) return null
    return new Node(this, attrs, content.append(after), Mark.setFrom(marks))
  }

  // :: (Fragment, ?Object) → bool
  // Returns true if the given fragment is valid content for this node
  // type with the given attributes.
  validContent(content, attrs) {
    return this.contentExpr.matches(attrs, content)
  }

  get ephemeral() { return false }

  static compile(nodes, schema) {
    let result = Object.create(null)
    nodes.forEach((name, spec) => result[name] = new spec.type(name, schema))

    if (!result.doc) throw new RangeError("Every schema needs a 'doc' type")
    if (!result.text) throw new RangeError("Every schema needs a 'text' type")

    return result
  }
}
exports.NodeType = NodeType

// ;; Base type for block nodetypes.
class Block extends NodeType {
  get isBlock() { return true }
  get isTextblock() { return this.contentExpr.inlineContent }
}
exports.Block = Block

// ;; Base type for inline node types.
class Inline extends NodeType {
  get isInline() { return true }
}
exports.Inline = Inline

// ;; The text node type.
class Text extends Inline {
  get selectable() { return false }
  get isText() { return true }

  create(attrs, content, marks) {
    return new TextNode(this, this.computeAttrs(attrs), content, marks)
  }
  toDOM(node) { return node.text }
}
exports.Text = Text

// Attribute descriptors

// ;; Attributes are named values associated with nodes and marks.
// Each node type or mark type has a fixed set of attributes, which
// instances of this class are used to control. Attribute values must
// be JSON-serializable.
class Attribute {
  // :: (Object)
  // Create an attribute. `options` is an object containing the
  // settings for the attributes. The following settings are
  // supported:
  //
  // **`default`**`: ?any`
  //   : The default value for this attribute, to choose when no
  //     explicit value is provided.
  //
  // **`compute`**`: ?() → any`
  //   : A function that computes a default value for the attribute.
  //
  // Attributes that have no default or compute property must be
  // provided whenever a node or mark of a type that has them is
  // created.
  constructor(options = {}) {
    this.default = options.default
    this.compute = options.compute
  }

  get isRequired() {
    return this.default === undefined && !this.compute
  }
}
exports.Attribute = Attribute

// Marks

// ;; Like nodes, marks (which are associated with nodes to signify
// things like emphasis or being part of a link) are tagged with type
// objects, which are instantiated once per `Schema`.
class MarkType {
  constructor(name, rank, schema) {
    // :: string
    // The name of the mark type.
    this.name = name
    Object.defineProperty(this, "attrs", {value: copyObj(this.attrs)})
    this.rank = rank
    // :: Schema
    // The schema that this mark type instance is part of.
    this.schema = schema
    let defaults = defaultAttrs(this.attrs)
    this.instance = defaults && new Mark(this, defaults)
  }

  // :: bool
  // Whether this mark should be active when the cursor is positioned
  // at the end of the mark.
  get inclusiveRight() { return true }

  // :: (?Object) → Mark
  // Create a mark of this type. `attrs` may be `null` or an object
  // containing only some of the mark's attributes. The others, if
  // they have defaults, will be added.
  create(attrs) {
    if (!attrs && this.instance) return this.instance
    return new Mark(this, computeAttrs(this.attrs, attrs))
  }

  static compile(marks, schema) {
    let result = Object.create(null), rank = 0
    marks.forEach((name, markType) => result[name] = new markType(name, rank++, schema))
    return result
  }

  // :: ([Mark]) → [Mark]
  // When there is a mark of this type in the given set, a new set
  // without it is returned. Otherwise, the input set is returned.
  removeFromSet(set) {
    for (var i = 0; i < set.length; i++)
      if (set[i].type == this)
        return set.slice(0, i).concat(set.slice(i + 1))
    return set
  }

  // :: ([Mark]) → ?Mark
  // Tests whether there is a mark of this type in the given set.
  isInSet(set) {
    for (let i = 0; i < set.length; i++)
      if (set[i].type == this) return set[i]
  }
}
exports.MarkType = MarkType

// ;; #path=SchemaSpec #kind=interface
// An object describing a schema, as passed to the `Schema`
// constructor.

// :: union<Object<NodeSpec>, OrderedMap<NodeSpec>> #path=SchemaSpec.nodes
// The node types in this schema. Maps names to `NodeSpec` objects
// describing the node to be associated with that name. Their order is significant

// :: ?union<Object<constructor<MarkType>>, OrderedMap<constructor<MarkType>>> #path=SchemaSpec.marks
// The mark types that exist in this schema.

// ;; #path=NodeSpec #kind=interface

// :: constructor<NodeType> #path=NodeSpec.type
// The `NodeType` class to be used for this node.

// :: ?string #path=NodeSpec.content
// The content expression for this node, as parsed by
// `ContentExpr.parse`. When not given, the node does not allow any
// content.

// :: ?string #path=NodeSpec.group
// The group or space-separated groups to which this node belongs, as
// referred to in the content expressions for the schema.

// ;; Each document is based on a single schema, which provides the
// node and mark types that it is made up of (which, in turn,
// determine the structure it is allowed to have).
class Schema {
  // :: (SchemaSpec)
  // Construct a schema from a specification.
  constructor(spec) {
    // :: OrderedMap<NodeSpec> The node specs that the schema is based on.
    this.nodeSpec = OrderedMap.from(spec.nodes)
    // :: OrderedMap<constructor<MarkType>> The mark spec that the schema is based on.
    this.markSpec = OrderedMap.from(spec.marks)

    // :: Object<NodeType>
    // An object mapping the schema's node names to node type objects.
    this.nodes = NodeType.compile(this.nodeSpec, this)
    // :: Object<MarkType>
    // A map from mark names to mark type objects.
    this.marks = MarkType.compile(this.markSpec, this)
    for (let prop in this.nodes) {
      if (prop in this.marks)
        throw new RangeError(prop + " can not be both a node and a mark")
      let type = this.nodes[prop]
      type.contentExpr = ContentExpr.parse(type, this.nodeSpec.get(prop).content || "", this.nodeSpec)
    }

    // :: Object
    // An object for storing whatever values modules may want to
    // compute and cache per schema. (If you want to store something
    // in it, try to use property names unlikely to clash.)
    this.cached = Object.create(null)
    this.cached.wrappings = Object.create(null)

    this.node = this.node.bind(this)
    this.text = this.text.bind(this)
    this.nodeFromJSON = this.nodeFromJSON.bind(this)
    this.markFromJSON = this.markFromJSON.bind(this)
  }

  // :: (union<string, NodeType>, ?Object, ?union<Fragment, Node, [Node]>, ?[Mark]) → Node
  // Create a node in this schema. The `type` may be a string or a
  // `NodeType` instance. Attributes will be extended
  // with defaults, `content` may be a `Fragment`,
  // `null`, a `Node`, or an array of nodes.
  //
  // When creating a text node, `content` should be a string and is
  // interpreted as the node's text.
  //
  // This method is bound to the Schema, meaning you don't have to
  // call it as a method, but can pass it to higher-order functions
  // and such.
  node(type, attrs, content, marks) {
    if (typeof type == "string")
      type = this.nodeType(type)
    else if (!(type instanceof NodeType))
      throw new RangeError("Invalid node type: " + type)
    else if (type.schema != this)
      throw new RangeError("Node type from different schema used (" + type.name + ")")

    return type.createChecked(attrs, content, marks)
  }

  // :: (string, ?[Mark]) → Node
  // Create a text node in the schema. This method is bound to the
  // Schema. Empty text nodes are not allowed.
  text(text, marks) {
    return this.nodes.text.create(null, text, Mark.setFrom(marks))
  }

  // :: (string, ?Object) → Mark
  // Create a mark with the named type
  mark(name, attrs) {
    let spec = this.marks[name]
    if (!spec) throw new RangeError("No mark named " + name)
    return spec.create(attrs)
  }

  // :: (Object) → Node
  // Deserialize a node from its JSON representation. This method is
  // bound.
  nodeFromJSON(json) {
    return Node.fromJSON(this, json)
  }

  // :: (Object) → Mark
  // Deserialize a mark from its JSON representation. This method is
  // bound.
  markFromJSON(json) {
    let type = this.marks[json._]
    let attrs = null
    for (let prop in json) if (prop != "_") {
      if (!attrs) attrs = Object.create(null)
      attrs[prop] = json[prop]
    }
    return attrs ? type.create(attrs) : type.instance
  }

  // :: (string) → NodeType
  // Get the `NodeType` associated with the given name in
  // this schema, or raise an error if it does not exist.
  nodeType(name) {
    let found = this.nodes[name]
    if (!found) throw new RangeError("Unknown node type: " + name)
    return found
  }
}
exports.Schema = Schema
<|endoftext|>