diff --git a/package.json b/package.json index 2b407de7..959e357a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/stark", - "version": "1.0.0", + "version": "1.1.0", "description": "Icestark is a JavaScript library for multiple projects, Ice workbench solution.", "scripts": { "build": "rm -rf lib && tsc", @@ -46,6 +46,8 @@ "@commitlint/cli": "^7.5.2", "@commitlint/config-conventional": "^7.5.0", "@ice/spec": "^0.1.4", + "@testing-library/react": "^9.3.2", + "@testing-library/jest-dom": "^4.2.3", "@types/jest": "^24.0.12", "@types/node": "^12.0.0", "@types/path-to-regexp": "^1.7.0", @@ -54,13 +56,11 @@ "@types/url-parse": "^1.4.3", "codecov": "^3.4.0", "eslint": "^5.16.0", - "stylelint": "^10.1.0", "husky": "^2.2.0", "jest": "^24.7.1", - "jest-dom": "^3.4.0", "react": "^16.7.0", "react-dom": "^16.7.0", - "react-testing-library": "^7.0.0", + "stylelint": "^10.1.0", "ts-jest": "^24.0.2", "typescript": "^3.4.4" }, diff --git a/packages/icestark-app/package.json b/packages/icestark-app/package.json index 3334547b..183abe22 100644 --- a/packages/icestark-app/package.json +++ b/packages/icestark-app/package.json @@ -38,14 +38,14 @@ "@commitlint/cli": "^7.5.2", "@commitlint/config-conventional": "^7.5.0", "@ice/spec": "^0.1.4", + "@testing-library/jest-dom": "^4.2.3", "@types/jest": "^24.0.12", "@types/node": "^12.0.0", "codecov": "^3.4.0", "eslint": "^5.16.0", - "stylelint": "^10.1.0", "husky": "^2.2.0", "jest": "^24.7.1", - "jest-dom": "^3.4.0", + "stylelint": "^10.1.0", "ts-jest": "^24.0.2", "typescript": "^3.4.4" }, diff --git a/packages/icestark-app/tests/index.spec.tsx b/packages/icestark-app/tests/index.spec.tsx index 9d6af95e..53d69ef7 100644 --- a/packages/icestark-app/tests/index.spec.tsx +++ b/packages/icestark-app/tests/index.spec.tsx @@ -1,4 +1,4 @@ -import 'jest-dom/extend-expect'; +import '@testing-library/jest-dom/extend-expect'; import { getBasename, diff --git a/src/AppRoute.tsx b/src/AppRoute.tsx index 7b4eed1d..6148c5d0 100644 --- a/src/AppRoute.tsx +++ b/src/AppRoute.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; +import { AppHistory } from './appHistory'; import { loadAssets, emptyAssets } from './handleAssets'; -import { ICESTSRK_NOT_FOUND } from './constant'; import { setCache, getCache } from './cache'; const statusElementId = 'icestarkStatusContainer'; @@ -23,6 +23,25 @@ interface AppRouteState { // "hashbang" - “ajax crawlable” (deprecated by Google) hashes like #!/ and #!/sunshine/lollipops type hashType = 'hashbang' | 'noslash' | 'slash'; +interface Match { + params: Params; + isExact: boolean; + path: string; + url: string; +} + +interface Location { + pathname: string; + query: Query; + hash: string; +} + +export interface AppRouteComponentProps { + match: Match; + location: Location; + history: AppHistory; +} + export interface AppConfig { title?: string; hashType?: boolean | hashType; @@ -35,11 +54,12 @@ export interface AppConfig { export interface AppRouteProps extends AppConfig { path: string | string[]; - url: string | string[]; + url?: string | string[]; useShadow?: boolean; ErrorComponent?: any; LoadingComponent?: any; - NotFoundComponent?: any; + component?: React.ReactElement; + render?: (props?: AppRouteComponentProps) => React.ReactElement; forceRenderCount?: number; onAppEnter?: (appConfig: AppConfig) => void; onAppLeave?: (appConfig: AppConfig) => void; @@ -60,8 +80,6 @@ function getAppConfig(appRouteProps: AppRouteProps): AppConfig { 'useShadow', 'ErrorComponent', 'LoadingComponent', - 'NotFoundComponent', - 'useShadow', 'onAppEnter', 'onAppLeave', ]; @@ -84,8 +102,6 @@ export default class AppRoute extends React.Component { const { - path, url, title, rootId, ErrorComponent, LoadingComponent, - NotFoundComponent, useShadow, onAppEnter, onAppLeave, @@ -162,7 +176,7 @@ export default class AppRoute extends React.Component + */ +function renderComponent(Component: any, props = {}): React.ReactElement { + return React.isValidElement(Component) ? ( + React.cloneElement(Component, props) + ) : ( + + ); +} + export default class AppRouter extends React.Component { private originalPush: OriginalStateFunction = window.history.pushState; private originalReplace: OriginalStateFunction = window.history.replaceState; static defaultProps = { - ErrorComponent:
js bundle loaded error
, + onRouteChange: () => {}, + ErrorComponent: ({ err }) =>
{err}
, NotFoundComponent:
NotFound
, useShadow: false, }; @@ -76,15 +89,21 @@ export default class AppRouter extends React.Component { - this.setState({ url: ICESTSRK_NOT_FOUND }); - }); + window.addEventListener('icestark:not-found', this.triggerNotFound); } componentWillUnmount() { this.unHijackHistory(); + window.removeEventListener('icestark:not-found', this.triggerNotFound); } + /** + * Trigger NotFound + */ + triggerNotFound = () => { + this.setState({ url: ICESTSRK_NOT_FOUND }); + }; + /** * Hijack window.history */ @@ -103,7 +122,7 @@ export default class AppRouter extends React.Component { window.history.pushState = this.originalPush; @@ -157,7 +176,6 @@ export default class AppRouter extends React.Component - ); + return renderComponent(NotFoundComponent, {}); } } diff --git a/src/appHistory.ts b/src/appHistory.ts index 3ed714d6..78d23fbd 100644 --- a/src/appHistory.ts +++ b/src/appHistory.ts @@ -1,20 +1,25 @@ -const appHistory = { - push: (url: string) => { +export interface AppHistory { + push(path: string): void; + replace(path: string): void; +} + +const appHistory: AppHistory = { + push: (path: string) => { window.history.pushState( { forceRender: true, }, null, - url, + path, ); }, - replace: (url: string) => { + replace: (path: string) => { window.history.replaceState( { forceRender: true, }, null, - url, + path, ); }, }; diff --git a/src/handleAssets.ts b/src/handleAssets.ts index 05ac0cb1..2e862555 100644 --- a/src/handleAssets.ts +++ b/src/handleAssets.ts @@ -39,7 +39,7 @@ function loadAsset( element.addEventListener( 'error', () => { - callback(isCss ? undefined : new Error(`JS asset loaded error: ${url}`)); + callback(isCss ? undefined : `js asset loaded error: ${url}`); }, false, ); diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index d213dd22..62547c9b 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -1,11 +1,11 @@ -import 'react-testing-library/cleanup-after-each'; -import 'jest-dom/extend-expect'; +import '@testing-library/jest-dom/extend-expect'; import * as React from 'react'; -import { render } from 'react-testing-library'; +import { render, fireEvent } from '@testing-library/react'; import { AppRouter, AppRoute, AppLink, appHistory } from '../src/index'; import matchPath from '../src/matchPath'; -import { loadAssets } from '../src/handleAssets'; +import { loadAssets, recordAssets } from '../src/handleAssets'; +import { setCache, getCache } from '../src/cache'; describe('AppRouter', () => { test('render the AppRouter', () => { @@ -14,43 +14,137 @@ describe('AppRouter', () => { useShadow: false, NotFoundComponent:
NotFound
, }; - const { container, getByTestId } = render(); + const { getByTestId } = render(); - const appRouteNode = container.querySelector('.ice-stark-loaded'); const textNode = getByTestId('icestarkDefalut'); - expect(appRouteNode.childNodes.length).toBe(2); expect(textNode).toHaveTextContent('NotFound'); expect(props.onRouteChange).toHaveBeenCalledTimes(1); + + window.history.pushState({}, 'test', '/#/test'); + expect(props.onRouteChange).toHaveBeenCalledTimes(2); + + window.history.replaceState({ forceRender: true }, 'test2', '/#/test2'); + expect(props.onRouteChange).toHaveBeenCalledTimes(3); }); -}); -describe('AppRoute', () => { - test('render the AppRoute', () => { + test('test for AppRoute Component', () => { + window.history.pushState({}, 'test', '/'); + const props = { - path: '/', - url: [], - title: '主页', - useShadow: false, + onRouteChange: jest.fn(), LoadingComponent:
Loading
, }; - const { container } = render(); + const { container, rerender, unmount, getByText } = render( + + test} /> + , + ); + + expect(container.innerHTML).toContain('test'); + expect(props.onRouteChange).toHaveBeenCalledTimes(1); + + rerender( + + ( +
+ test + + +
+ )} + /> +
, + ); + + expect(container.innerHTML).toContain('test'); + expect(props.onRouteChange).toHaveBeenCalledTimes(1); + + fireEvent.click(getByText(/Jump NotFound/i)); + expect(container.innerHTML).toContain('NotFound'); + + fireEvent.click(getByText(/Jump Hash/i)); + expect(props.onRouteChange).toHaveBeenCalledTimes(2); + + /** + * test for HashType + */ + rerender( + + + , + ); const appRouteNode = container.querySelector('.ice-stark-loading'); + expect(container.innerHTML).toContain('Loading'); expect(appRouteNode.childNodes.length).toBe(2); - }); - test('render the AppRoute without LoadingComponent', () => { - const props = { - path: '/', - url: [], - title: '主页', - useShadow: false, - }; - const { container } = render(); + /** + * Load assets error + */ + rerender( + + + , + ); + expect(container.innerHTML).toContain('Loading'); - const appRouteNode = container.querySelector('.ice-stark-loading'); - expect(appRouteNode.childNodes.length).toBe(1); + const dynamicScript = document.querySelector('[icestark=dynamic]'); + expect(dynamicScript.id).toBe('icestark-js-0'); + expect(dynamicScript.getAttribute('src')).toBe('//icestark.com/js/index.js'); + + dynamicScript.dispatchEvent(new ErrorEvent('error')); + expect(container.innerHTML).toContain('js asset loaded error: //icestark.com/js/index.js'); + + /** + * Load assets success + */ + setCache('appLeave', () => {}); + + // HTMLElement.attachShadow = jest.fn(); + + rerender( + + + , + ); + expect(getCache('appLeave')).toBeNull(); + + // js load success + const dynamicScriptLoaded = document.querySelector('script[icestark=dynamic]'); + expect(dynamicScriptLoaded.getAttribute('id')).toBe('icestark-js-0'); + expect(dynamicScriptLoaded.getAttribute('type')).toBe('text/javascript'); + expect(dynamicScriptLoaded.getAttribute('src')).toBe('//icestark.com/js/index.js'); + + dynamicScriptLoaded.dispatchEvent(new Event('load')); + expect(container.querySelector('.ice-stark-loading').childNodes.length).toBe(1); + + // css load success + const dynamicLinkLoaded = document.querySelector('link[icestark=dynamic]'); + expect(dynamicLinkLoaded.getAttribute('id')).toBe('icestark-css-0'); + expect(dynamicLinkLoaded.getAttribute('rel')).toBe('stylesheet'); + expect(dynamicLinkLoaded.getAttribute('href')).toBe('//icestark.com/css/index.css'); + + dynamicLinkLoaded.dispatchEvent(new Event('load')); + expect(container.querySelector('.ice-stark-loaded').childNodes.length).toBe(1); + + unmount(); }); }); @@ -58,34 +152,68 @@ describe('AppLink', () => { test('render the AppLink', () => { const className = 'ice-stark-test'; const props = { - to: 'www.taobao.com', + to: '/test', className, }; const TestText = 'This is a test'; - const { container } = render({TestText}); + const { container, getByText, rerender } = render({TestText}); const appLinkNode = container.querySelector(`.${className}`); expect(appLinkNode).toHaveTextContent(TestText); expect(appLinkNode).toHaveAttribute('href'); + + const mockPushState = jest.fn(); + window.history.pushState = mockPushState; + + fireEvent.click(getByText(/This is a test/i)); + expect(mockPushState.mock.calls.length).toBe(1); + + rerender( + + {TestText} + , + ); + const mockReplaceState = jest.fn(); + window.history.replaceState = mockReplaceState; + + fireEvent.click(getByText(/This is a test/i)); + expect(mockReplaceState.mock.calls.length).toBe(1); }); }); describe('matchPath', () => { test('matchPath', () => { - expect(matchPath('/test/123', { path: '/test' })).not.toBeNull(); - expect(matchPath('/test/123', { path: '/test/:id' })).not.toBeNull(); - expect(matchPath('/test/123', { path: '/test', exact: true })).toBeNull(); + let match = matchPath('/test/123'); + expect(match).toBeNull(); + + match = matchPath('/test/123', '/test'); + expect(match.url).toBe('/test'); + + match = matchPath('/test/123', { path: '/test' }); + expect(match.url).toBe('/test'); + + match = matchPath('/test/123', { path: '/test/:id' }); + expect(match.url).toBe('/test/123'); + expect(match.params.id).toBe('123'); + + match = matchPath('/test/123', { path: ['/test/:id', '/test/:id/detail'] }); + expect(match.url).toBe('/test/123'); + expect(match.path).toBe('/test/:id'); + expect(match.params.id).toBe('123'); + + match = matchPath('/test/123', { path: '/test', exact: true }); + expect(match).toBeNull(); }); }); -describe('loadAssets', () => { +describe('handleAssets', () => { test('loadAssets', () => { loadAssets( [ - 'http://icestark.com/test.js', - 'http://icestark.com/test.css', - 'http://icestark.com/test1.js', + 'http://icestark.com/js/index.js', + 'http://icestark.com/css/index.css', + 'http://icestark.com/js/test1.js', ], false, jest.fn(), @@ -94,10 +222,36 @@ describe('loadAssets', () => { const jsElement0 = document.getElementById('icestark-js-0'); const jsElement1 = document.getElementById('icestark-js-1'); - expect((jsElement0 as HTMLScriptElement).src).toEqual('http://icestark.com/test.js'); + expect((jsElement0 as HTMLScriptElement).src).toEqual('http://icestark.com/js/index.js'); expect((jsElement0 as HTMLScriptElement).async).toEqual(false); - expect((jsElement1 as HTMLScriptElement).src).toEqual('http://icestark.com/test1.js'); + expect((jsElement1 as HTMLScriptElement).src).toEqual('http://icestark.com/js/test1.js'); expect((jsElement1 as HTMLScriptElement).async).toEqual(false); + + recordAssets(); + + expect(jsElement0.getAttribute('icestark')).toEqual('static'); + expect(jsElement1.getAttribute('icestark')).toEqual('static'); + }); + + test('recordAssets', () => { + const jsElement = document.createElement('script'); + jsElement.id = 'icestark-script'; + + const linkElement = document.createElement('link'); + linkElement.id = 'icestark-link'; + + const styleElement = document.createElement('style'); + styleElement.id = 'icestark-style'; + + document.body.appendChild(jsElement); + document.body.appendChild(linkElement); + document.body.appendChild(styleElement); + + recordAssets(); + + expect(jsElement.getAttribute('icestark')).toEqual('static'); + expect(linkElement.getAttribute('icestark')).toEqual('static'); + expect(styleElement.getAttribute('icestark')).toEqual('static'); }); });