Add tests for client (#33)

* fix(fs/local): force closing fds and add backoffs, unit tests

* test(client/web): add unit tests
This commit is contained in:
Hexxa 2021-01-30 10:01:38 +08:00 committed by GitHub
parent 1ff1e2024e
commit ea3400aca6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 301 additions and 70 deletions

View file

@ -1,13 +1,20 @@
import { List, Map } from "immutable"; import { List, Map } from "immutable";
import { mock, instance } from "ts-mockito"; import { mock, instance, anyString, when } from "ts-mockito";
import { initWithWorker } from "../core_state"; import { ICoreState, initWithWorker, mockState } from "../core_state";
import { makePromise, makeNumberResponse } from "../../test/helpers"; import {
import { Updater } from "../browser"; makePromise,
makeNumberResponse,
mockUpdate,
} from "../../test/helpers";
import { Updater, Browser } from "../browser";
import { MockUsersClient } from "../../client/users_mock"; import { MockUsersClient } from "../../client/users_mock";
import { FilesClient } from "../../client/files_mock"; import { UsersClient } from "../../client/users";
import { MetadataResp } from "../../client"; import { FilesClient } from "../../client/files";
import { MockWorker } from "../../worker/interface"; import { FilesClient as MockFilesClient } from "../../client/files_mock";
import { MetadataResp, UploadInfo } from "../../client";
import { MockWorker, UploadEntry } from "../../worker/interface";
import { UploadMgr } from "../../worker/upload_mgr";
describe("Browser", () => { describe("Browser", () => {
const mockWorkerClass = mock(MockWorker); const mockWorkerClass = mock(MockWorker);
@ -41,7 +48,7 @@ describe("Browser", () => {
]; ];
const usersClient = new MockUsersClient(""); const usersClient = new MockUsersClient("");
const filesClient = new FilesClient(""); const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) { for (let i = 0; i < tests.length; i++) {
const tc = tests[i]; const tc = tests[i];
@ -102,7 +109,7 @@ describe("Browser", () => {
]; ];
const usersClient = new MockUsersClient(""); const usersClient = new MockUsersClient("");
const filesClient = new FilesClient(""); const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) { for (let i = 0; i < tests.length; i++) {
const tc = tests[i]; const tc = tests[i];
@ -163,7 +170,7 @@ describe("Browser", () => {
]; ];
const usersClient = new MockUsersClient(""); const usersClient = new MockUsersClient("");
const filesClient = new FilesClient(""); const filesClient = new MockFilesClient("");
for (let i = 0; i < tests.length; i++) { for (let i = 0; i < tests.length; i++) {
const tc = tests[i]; const tc = tests[i];
@ -190,4 +197,72 @@ describe("Browser", () => {
}); });
} }
}); });
xtest("Browser: deleteUploading", async () => {
interface TestCase {
deleteFile: string;
preState: ICoreState;
postState: ICoreState;
}
const tcs: any = [
{
deleteFile: "./path/file",
preState: {
browser: {
uploadings: List<UploadInfo>([
{
realFilePath: "./path/file",
size: 1,
uploaded: 0,
},
]),
update: mockUpdate,
},
},
postState: {
browser: {
uploadings: List<UploadInfo>(),
update: mockUpdate,
},
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
state.panel.browser = patch.browser;
return state;
};
const mockFilesClientClass = mock(FilesClient);
when(mockFilesClientClass.deleteUploading(anyString())).thenResolve({
status: 200,
statusText: "",
data: "",
});
// TODO: the return should dpends on test case
when(mockFilesClientClass.listUploadings()).thenResolve({
status: 200,
statusText: "",
data: { uploadInfos: Array<UploadInfo>() },
});
const mockUsersClientClass = mock(UsersClient);
const mockFilesClient = instance(mockFilesClientClass);
const mockUsersClient = instance(mockUsersClientClass);
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
// const existingFileName = preState.panel.browser.uploadings.get(0).realFilePath;
const infos:Map<string, UploadEntry> = Map();
UploadMgr._setInfos(infos);
const component = new Browser(preState.panel.browser);
Updater.init(preState.panel.browser);
Updater.setClients(mockUsersClient, mockFilesClient);
component.deleteUploading(tc.deleteFile);
expect(Updater.props).toEqual(postState.panel.browser);
});
});
}); });

View file

@ -0,0 +1,49 @@
import { Set } from "immutable";
import { ICoreState, mockState } from "../core_state";
import { Panes, Updater } from "../panes";
import { mockUpdate } from "../../test/helpers";
describe("Panes", () => {
test("Panes: closePane", async () => {
interface TestCase {
preState: ICoreState;
postState: ICoreState;
}
const tcs: any = [
{
preState: {
panes: {
displaying: "settings",
paneNames: Set<string>(["settings", "login"]),
update: mockUpdate,
},
},
postState: {
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
update: mockUpdate,
},
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
state.panel.panes = patch.panes;
return state;
};
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
const component = new Panes(preState.panel.panes);
Updater.init(preState.panel.panes);
component.closePane();
expect(Updater.props).toEqual(postState.panel.panes);
});
});
});

View file

@ -0,0 +1,52 @@
import { Set } from "immutable";
// import { mock, instance } from "ts-mockito";
import { ICoreState, mockState } from "../core_state";
import { RootFrame } from "../root_frame";
import { Updater } from "../panes";
describe("RootFrame", () => {
test("component: showSettings", async () => {
interface TestCase {
preState: ICoreState;
postState: ICoreState;
}
const mockUpdate = (apply: (prevState: ICoreState) => ICoreState): void => {};
const tcs: any = [
{
preState: {
displaying: "",
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
},
update: mockUpdate,
},
postState: {
displaying: "settings",
panes: {
displaying: "settings",
paneNames: Set<string>(["settings", "login"]),
},
update: mockUpdate,
},
},
];
const setState = (patch: any, state: ICoreState): ICoreState => {
return { ...state, panel: { ...state.panel, ...patch } };
};
tcs.forEach((tc: TestCase) => {
const preState = setState(tc.preState, mockState());
const postState = setState(tc.postState, mockState());
const component = new RootFrame(preState.panel);
Updater.init(preState.panel.panes);
component.showSettings();
expect(Updater.props).toEqual(postState.panel.panes);
});
});
});

View file

@ -46,7 +46,7 @@ function getItemPath(dirPath: string, itemName: string): string {
} }
export class Updater { export class Updater {
private static props: Props; static props: Props;
private static usersClient: IUsersClient; private static usersClient: IUsersClient;
private static filesClient: IFilesClient; private static filesClient: IFilesClient;
@ -160,8 +160,12 @@ export class Updater {
static addUploadFiles = (fileList: FileList, len: number) => { static addUploadFiles = (fileList: FileList, len: number) => {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const filePath = getItemPath(
Updater.props.dirPath.join("/"),
fileList[i].name
);
// do not wait for the promise // do not wait for the promise
UploadMgr.add(fileList[i], fileList[i].name); UploadMgr.add(fileList[i], filePath);
} }
Updater.setUploadings(UploadMgr.list()); Updater.setUploadings(UploadMgr.list());
}; };
@ -215,9 +219,6 @@ export class Browser extends React.Component<Props, State, {}> {
}); });
} }
// showPane = () => {
// this.setState({ show: !this.state.show });
// };
onInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => { onInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ inputValue: ev.target.value }); this.setState({ inputValue: ev.target.value });
}; };
@ -245,7 +246,15 @@ export class Browser extends React.Component<Props, State, {}> {
}; };
onMkDir = () => { onMkDir = () => {
Updater.mkDir(this.state.inputValue) if (this.state.inputValue === "") {
alert("folder name can not be empty");
}
const dirPath = getItemPath(
this.props.dirPath.join("/"),
this.state.inputValue
);
Updater.mkDir(dirPath)
.then(() => { .then(() => {
this.setState({ inputValue: "" }); this.setState({ inputValue: "" });
return Updater.setItems(this.props.dirPath); return Updater.setItems(this.props.dirPath);
@ -433,7 +442,7 @@ export class Browser extends React.Component<Props, State, {}> {
<button <button
onClick={() => this.select(item.name)} onClick={() => this.select(item.name)}
className={`white-font ${isSelected ? "blue0-bg" : ""}`} className={`white-font ${isSelected ? "blue0-bg" : ""}`}
style={{width: "8rem", display: "inline-block"}} style={{ width: "8rem", display: "inline-block" }}
> >
{isSelected ? "Deselect" : "Select"} {isSelected ? "Deselect" : "Select"}
</button> </button>
@ -467,7 +476,7 @@ export class Browser extends React.Component<Props, State, {}> {
type="button" type="button"
onClick={() => this.select(item.name)} onClick={() => this.select(item.name)}
className={`white-font ${isSelected ? "blue0-bg" : ""}`} className={`white-font ${isSelected ? "blue0-bg" : ""}`}
style={{width: "8rem", display: "inline-block"}} style={{ width: "8rem", display: "inline-block" }}
> >
{isSelected ? "Deselect" : "Select"} {isSelected ? "Deselect" : "Select"}
</button> </button>

View file

@ -3,11 +3,19 @@ import { List, Set } from "immutable";
import BgWorker from "../worker/upload.bg.worker"; import BgWorker from "../worker/upload.bg.worker";
import { FgWorker } from "../worker/upload.fgworker"; import { FgWorker } from "../worker/upload.fgworker";
import { Props as PanelProps } from "./panel"; import { Props as PanelProps } from "./root_frame";
import { Item } from "./browser"; import { Item } from "./browser";
import { UploadInfo } from "../client"; import { UploadInfo } from "../client";
import { UploadMgr, IWorker } from "../worker/upload_mgr"; import { UploadMgr, IWorker } from "../worker/upload_mgr";
export class BaseUpdater {
public static props: any;
public static init = (props: any) => (BaseUpdater.props = { ...props });
public static apply = (prevState: ICoreState): ICoreState => {
throw Error("apply is not implemented");
};
}
export interface IContext { export interface IContext {
update: (targetStatePatch: any) => void; update: (targetStatePatch: any) => void;
} }
@ -25,11 +33,8 @@ export function initWithWorker(worker: IWorker): ICoreState {
export function init(): ICoreState { export function init(): ICoreState {
const scripts = Array.from(document.querySelectorAll("script")); const scripts = Array.from(document.querySelectorAll("script"));
if (!Worker) { const worker = Worker == null ? new FgWorker() : new BgWorker();
alert("web worker is not supported");
}
const worker = new BgWorker();
UploadMgr.init(worker); UploadMgr.init(worker);
return initState(); return initState();
} }
@ -65,3 +70,31 @@ export function initState(): ICoreState {
}, },
}; };
} }
export function mockState(): ICoreState {
return {
ctx: undefined,
isVertical: false,
panel: {
displaying: "browser",
authPane: {
authed: false,
},
browser: {
isVertical: false,
dirPath: List<string>(["."]),
items: List<Item>([]),
uploadings: List<UploadInfo>([]),
uploadValue: "",
uploadFiles: List<File>([]),
},
panes: {
displaying: "",
paneNames: Set<string>(["settings", "login"]),
login: {
authed: false,
},
},
},
};
}

View file

@ -13,7 +13,7 @@ export interface Props {
} }
export class Updater { export class Updater {
private static props: Props; static props: Props;
static init = (props: Props) => (Updater.props = { ...props }); static init = (props: Props) => (Updater.props = { ...props });
@ -44,17 +44,15 @@ export class Updater {
export interface State {} export interface State {}
export class Panes extends React.Component<Props, State, {}> { export class Panes extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
constructor(p: Props) { constructor(p: Props) {
super(p); super(p);
Updater.init(p); Updater.init(p);
this.update = p.update;
} }
closePane = () => { closePane = () => {
if (this.props.displaying !== "login") { if (this.props.displaying !== "login") {
Updater.displayPane(""); Updater.displayPane("");
this.update(Updater.updateState); this.props.update(Updater.updateState);
} }
}; };
@ -66,8 +64,8 @@ export class Panes extends React.Component<Props, State, {}> {
} }
const panesMap: Map<string, JSX.Element> = Map({ const panesMap: Map<string, JSX.Element> = Map({
settings: <PaneSettings login={this.props.login} update={this.update} />, settings: <PaneSettings login={this.props.login} update={this.props.update} />,
login: <AuthPane authed={this.props.login.authed} update={this.update} />, login: <AuthPane authed={this.props.login.authed} update={this.props.update} />,
}); });
const panes = panesMap.keySeq().map( const panes = panesMap.keySeq().map(

View file

@ -1,24 +1,22 @@
import * as React from "react"; import * as React from "react";
import { ICoreState } from "./core_state"; import { ICoreState, BaseUpdater } from "./core_state";
import { Browser, Props as BrowserProps } from "./browser"; import { Browser, Props as BrowserProps } from "./browser";
import { AuthPane, Props as AuthPaneProps } from "./pane_login"; import { Props as PaneLoginProps } from "./pane_login";
import { Panes, Props as PanesProps, Updater as PanesUpdater } from "./panes"; import { Panes, Props as PanesProps, Updater as PanesUpdater } from "./panes";
export interface Props { export interface Props {
displaying: string; displaying: string;
browser: BrowserProps; browser: BrowserProps;
authPane: AuthPaneProps; authPane: PaneLoginProps;
panes: PanesProps; panes: PanesProps;
update?: (updater: (prevState: ICoreState) => ICoreState) => void; update?: (updater: (prevState: ICoreState) => ICoreState) => void;
} }
export class Updater { export class Updater {
private static props: Props; public static props: Props;
public static init = (props: Props) => (BaseUpdater.props = { ...props });
static init = (props: Props) => (Updater.props = { ...props }); public static apply = (prevState: ICoreState): ICoreState => {
static setPanel = (prevState: ICoreState): ICoreState => {
return { return {
...prevState, ...prevState,
panel: { ...prevState.panel, ...Updater.props }, panel: { ...prevState.panel, ...Updater.props },
@ -27,20 +25,19 @@ export class Updater {
} }
export interface State {} export interface State {}
export class Panel extends React.Component<Props, State, {}> { export class RootFrame extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
constructor(p: Props) { constructor(p: Props) {
super(p); super(p);
Updater.init(p); Updater.init(p);
this.update = p.update;
} }
showSettings = () => { showSettings = () => {
PanesUpdater.displayPane("settings"); PanesUpdater.displayPane("settings");
this.update(PanesUpdater.updateState); this.props.update(PanesUpdater.updateState);
}; };
render() { render() {
const update = this.props.update;
return ( return (
<div className="theme-white desktop"> <div className="theme-white desktop">
<div id="bg" className="bg bg-img font-m"> <div id="bg" className="bg bg-img font-m">
@ -48,7 +45,7 @@ export class Panel extends React.Component<Props, State, {}> {
displaying={this.props.panes.displaying} displaying={this.props.panes.displaying}
paneNames={this.props.panes.paneNames} paneNames={this.props.panes.paneNames}
login={this.props.authPane} login={this.props.authPane}
update={this.update} update={update}
/> />
<div <div
@ -78,16 +75,18 @@ export class Panel extends React.Component<Props, State, {}> {
dirPath={this.props.browser.dirPath} dirPath={this.props.browser.dirPath}
items={this.props.browser.items} items={this.props.browser.items}
uploadings={this.props.browser.uploadings} uploadings={this.props.browser.uploadings}
update={this.update} update={update}
uploadFiles={this.props.browser.uploadFiles} uploadFiles={this.props.browser.uploadFiles}
uploadValue={this.props.browser.uploadValue} uploadValue={this.props.browser.uploadValue}
isVertical={this.props.browser.isVertical} isVertical={this.props.browser.isVertical}
/> />
</div> </div>
<div className="container-center black0-font tail margin-t-xl margin-b-xl"> <div className="container-center black0-font tail margin-t-xl margin-b-xl">
<a href="https://github.com/ihexxa/quickshare">Quickshare</a> - <a href="https://github.com/ihexxa/quickshare">Quickshare</a> -
sharing in simple way. sharing in simple way.
</div> </div>
</div> </div>
</div> </div>
); );

View file

@ -1,40 +1,30 @@
import * as React from "react"; import * as React from "react";
import { ICoreState, init } from "./core_state"; import { ICoreState, init } from "./core_state";
import { Panel } from "./panel"; import { RootFrame } from "./root_frame";
export interface Props {} export interface Props {}
export interface State extends ICoreState {} export interface State extends ICoreState {}
export class UpdaterBase {
private static props: any;
static init = (props: any) => (UpdaterBase.props = {...props});
}
export class StateMgr extends React.Component<Props, State, {}> { export class StateMgr extends React.Component<Props, State, {}> {
constructor(p: Props) { constructor(p: Props) {
super(p); super(p);
this.state = init(); this.state = init();
} }
// TODO: any can be eliminated by adding union type of children states update = (apply: (prevState: ICoreState) => ICoreState): void => {
update = (updater: (prevState:ICoreState) => ICoreState): void => { this.setState(apply(this.state));
console.log("before", this.state)
this.setState(updater(this.state));
console.log("after", this.state)
}; };
render() { render() {
return ( return (
<div> <RootFrame
<Panel authPane={this.state.panel.authPane}
authPane = {this.state.panel.authPane} displaying={this.state.panel.displaying}
displaying={this.state.panel.displaying} update={this.update}
update={this.update} browser={this.state.panel.browser}
browser={this.state.panel.browser} panes={this.state.panel.panes}
panes={this.state.panel.panes} />
/>
</div>
); );
} }
} }

View file

@ -1,4 +1,5 @@
import { Response } from "../client"; import { Response } from "../client";
import { ICoreState } from "../components/core_state";
export const makePromise = (ret: any): Promise<any> => { export const makePromise = (ret: any): Promise<any> => {
return new Promise<any>((resolve) => { return new Promise<any>((resolve) => {
@ -13,3 +14,5 @@ export const makeNumberResponse = (status: number): Promise<Response> => {
data: {}, data: {},
}); });
}; };
export const mockUpdate = (apply: (prevState: ICoreState) => ICoreState): void => {};

View file

@ -14,7 +14,7 @@ export class UploadWorker {
private file: File = undefined; private file: File = undefined;
private filePath: string = undefined; private filePath: string = undefined;
private uploader: FileUploader = undefined; private uploader: FileUploader = undefined;
sendEvent = (resp: FileWorkerResp):void => { sendEvent = (resp: FileWorkerResp): void => {
// TODO: make this abstract // TODO: make this abstract
throw new Error("not implemented"); throw new Error("not implemented");
}; };
@ -30,6 +30,8 @@ export class UploadWorker {
stopUploader = () => { stopUploader = () => {
if (this.uploader != null) { if (this.uploader != null) {
this.uploader.stop(); this.uploader.stop();
this.file = undefined;
this.filePath = undefined;
} }
}; };
getFilePath = (): string => { getFilePath = (): string => {
@ -49,6 +51,7 @@ export class UploadWorker {
// find the first qualified task // find the first qualified task
const syncReq = req as SyncReq; const syncReq = req as SyncReq;
const infoArray = syncReq.infos; const infoArray = syncReq.infos;
for (let i = 0; i < infoArray.length; i++) { for (let i = 0; i < infoArray.length; i++) {
if ( if (
infoArray[i].runnable && infoArray[i].runnable &&
@ -59,6 +62,11 @@ export class UploadWorker {
this.startUploader(infoArray[i].file, infoArray[i].filePath); this.startUploader(infoArray[i].file, infoArray[i].filePath);
} }
break; break;
} else if (
!infoArray[i].runnable &&
infoArray[i].filePath == this.filePath
) {
this.stopUploader();
} }
} }
break; break;

View file

@ -8,6 +8,7 @@ const speedDownRatio = 0.5;
const speedUpRatio = 1.05; const speedUpRatio = 1.05;
const createRetryLimit = 2; const createRetryLimit = 2;
const uploadRetryLimit = 1024; const uploadRetryLimit = 1024;
const backoffMax = 2000;
export interface IFileUploader { export interface IFileUploader {
stop: () => void; stop: () => void;
@ -71,6 +72,13 @@ export class FileUploader {
this.client = client; this.client = client;
}; };
backOff = async (): Promise<void> => {
return new Promise((resolve) => {
const delay = Math.floor(Math.random() * backoffMax);
setTimeout(resolve, delay);
});
};
start = async (): Promise<boolean> => { start = async (): Promise<boolean> => {
let resp: Response; let resp: Response;
@ -81,6 +89,7 @@ export class FileUploader {
return await this.upload(); return await this.upload();
} }
} catch (e) { } catch (e) {
await this.backOff();
console.error(e); console.error(e);
} }
} }
@ -153,6 +162,8 @@ export class FileUploader {
); );
break; break;
} }
await this.backOff();
} }
} catch (e) { } catch (e) {
this.errMsgs.push(e.toString()); this.errMsgs.push(e.toString());

View file

@ -99,6 +99,16 @@ func (fs *LocalFS) translate(name string) (string, error) {
} }
func (fs *LocalFS) Create(path string) error { func (fs *LocalFS) Create(path string) error {
fs.opensMtx.Lock()
defer fs.opensMtx.Unlock()
if len(fs.opens) > fs.opensLimit {
err := fs.closeOpens(true, map[string]bool{})
if err != nil {
return fmt.Errorf("too many opens and fail to clean: %w", err)
}
return ErrTooManyOpens
}
fullpath, err := fs.translate(path) fullpath, err := fs.translate(path)
if err != nil { if err != nil {
return err return err
@ -109,12 +119,6 @@ func (fs *LocalFS) Create(path string) error {
return err return err
} }
fs.opensMtx.Lock()
defer fs.opensMtx.Unlock()
if len(fs.opens) > fs.opensLimit {
return ErrTooManyOpens
}
fs.opens[fullpath] = &fileInfo{ fs.opens[fullpath] = &fileInfo{
lastAccess: time.Now(), lastAccess: time.Now(),
fd: fd, fd: fd,