瀏覽代碼

Adding functional test.

dom111 2 年之前
父節點
當前提交
09fc82e1ad

+ 10 - 5
.github/workflows/build-on-push.yml

@@ -1,6 +1,12 @@
 name: Build and test application on push to remote
 
-on: [push]
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
 
 jobs:
   build:
@@ -9,7 +15,6 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: 12
-      - run: yarn install
-      - run: yarn build
-      - run: yarn test
+          node-version: 16
+      - run: make build
+      - run: make test

+ 6 - 13
Makefile

@@ -1,19 +1,12 @@
 SHELL = /bin/bash
 
 .PHONY: build
-build: docker-compose.override.yml
-	docker-compose run --rm web npm run build
+build: node_modules
+	npm run build
 
 .PHONY: test
-test: docker-compose.override.yml
-	docker-compose run --rm test npm run test
+test: node_modules
+	docker-compose run --rm -e BASE_URL=http://webdav test npm run test
 
-docker-compose.override.yml:
-	@echo "version: '3'" > docker-compose.override.yml
-	@echo >> docker-compose.override.yml
-	@echo "services:" >> docker-compose.override.yml
-	@echo "  web:" >> docker-compose.override.yml
-	@echo "    user: `id -u`:`id -g`" >> docker-compose.override.yml
-
-dist/js/app.js:
-dist/js/app.js: build
+node_modules:
+	npm install

+ 1 - 2
README.md

@@ -8,7 +8,7 @@ without the need for using a third party application.
 The application has since been rewritten to not rely on jQuery and use more modern methods and provide a single runtime
 file. Now that there's more separation between the interface code and the library code, I'd like to investigate using
 other frontend approaches to see which I prefer (and also to weigh up the differences between the currently available
-frameworks). There's still work to do around code separation andhopefully this will be something I can continue to work
+frameworks). There's still work to do around code separation and hopefully this will be something I can continue to work
 on (as time allows) I feel it's at least as stable as the previous version.
 
 ## Tested in:
@@ -16,7 +16,6 @@ on (as time allows) I feel it's at least as stable as the previous version.
 - Chrome
 - Firefox
 - Edge
-- IE11 (I may drop support for this to reduce the package size in the future - unless anyone REALY needs it?)
 
 ## Implementations
 

+ 5 - 6
TODO.md

@@ -1,12 +1,11 @@
+- [x] Add more unit tests for the UI
+- [x] Add end-to-end UI testing
+- [x] Move to TypeScript
+- [ ] Add drag and drop tests
 - [ ] Allow uploading of directories ([#48](https://github.com/dom111/webdav-js/issues/48))
 - [ ] Add functionality for copying and moving files and directories
 - [ ] Add progress bar for file uploads
-- [ ] Add more unit tests for the UI
-- [ ] Add end-to-end UI testing (although it seems that drag and drop might be a problem)
 - [ ] Support keyboard navigation whilst overlay is visible
-- [ ] Improve code in `item.js` - maybe split out the functionality into each action?
-- [ ] Look into moving to TypeScript
 - [ ] ReactJS implementation
 - [ ] VueJS implementation
-- [ ] Native Web Components implementation
-- [ ] Angular implementation
+- [ ] Maybe a refactor...

+ 0 - 5
build/sass.sh

@@ -1,5 +0,0 @@
-> assets/css/style.css;
-for file in node_modules/{basiclightbox/src/styles/main.scss,prismjs/themes/prism.css} assets/scss/style.scss; do
-  echo '/* '$file' */' >> assets/css/style.css;
-  npm run --silent node-sass -- --output-style=expanded $file >> assets/css/style.css;
-done

+ 1 - 1
docker-compose.yml

@@ -28,4 +28,4 @@ services:
     # https://stackoverflow.com/a/53975412/3145856
     # https://github.com/docker/compose/issues/5574
     security_opt:
-      - seccomp:"./docker/test/chrome.json"
+      - seccomp:./docker/test/chrome.json

+ 35 - 56
index.html

@@ -44,13 +44,41 @@
         display: none;
       }
     </style>
+    <style>
+      .github-corner:hover .octo-arm {
+        animation: octocat-wave 560ms ease-in-out;
+      }
+      @keyframes octocat-wave {
+        0%,
+        100% {
+          transform: rotate(0);
+        }
+        20%,
+        60% {
+          transform: rotate(-25deg);
+        }
+        40%,
+        80% {
+          transform: rotate(10deg);
+        }
+      }
+      @media (max-width: 500px) {
+        .github-corner:hover .octo-arm {
+          animation: none;
+        }
+        .github-corner .octo-arm {
+          animation: octocat-wave 560ms ease-in-out;
+        }
+      }
+    </style>
   </head>
   <body>
     <a
       href="https://github.com/dom111/webdav-js"
       class="github-corner"
       aria-label="View source on Github"
-      ><svg
+    >
+      <svg
         width="80"
         height="80"
         viewBox="0 0 250 250"
@@ -75,34 +103,9 @@
           d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
           fill="currentColor"
           class="octo-body"
-        ></path></svg></a
-    ><style>
-      .github-corner:hover .octo-arm {
-        animation: octocat-wave 560ms ease-in-out;
-      }
-      @keyframes octocat-wave {
-        0%,
-        100% {
-          transform: rotate(0);
-        }
-        20%,
-        60% {
-          transform: rotate(-25deg);
-        }
-        40%,
-        80% {
-          transform: rotate(10deg);
-        }
-      }
-      @media (max-width: 500px) {
-        .github-corner:hover .octo-arm {
-          animation: none;
-        }
-        .github-corner .octo-arm {
-          animation: octocat-wave 560ms ease-in-out;
-        }
-      }
-    </style>
+        ></path>
+      </svg>
+    </a>
 
     <div class="jumbotron">
       <div class="container">
@@ -125,14 +128,14 @@
             cross-browser addition to the bookmarks of anyone that has to
             interact with WebDAV. It supports previewing of many common
             filetypes (syntax highlighting for code, previews for
-            images/videos/fonts), drag and drop file uplaods and history state
+            images/videos/fonts), drag and drop file uploads and history state
             (for back button navigation).
           </p>
           <p>
             Whilst this started out as a very simple bookmarklet with some basic
             styling (and it's still not much more than that!), I'd like to
-            continue improve it somewhat, adding in new features and using it as
-            a testbed for front-end framework experience. I'd like to
+            continue to improve it somewhat, adding in new features and using it
+            as a testbed for front-end framework experience. I'd like to
             investigate more thorough testing using it too, ideally performing
             full end-to-end testing for all the features currently implemented.
           </p>
@@ -202,29 +205,5 @@
         event.preventDefault();
       });
     </script>
-    <script>
-      (function (i, s, o, g, r, a, m) {
-        i['GoogleAnalyticsObject'] = r;
-        (i[r] =
-          i[r] ||
-          function () {
-            (i[r].q = i[r].q || []).push(arguments);
-          }),
-          (i[r].l = 1 * new Date());
-        (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);
-        a.async = 1;
-        a.src = g;
-        m.parentNode.insertBefore(a, m);
-      })(
-        window,
-        document,
-        'script',
-        'https://www.google-analytics.com/analytics.js',
-        'ga'
-      );
-
-      ga('create', 'UA-5273748-7', 'auto');
-      ga('send', 'pageview');
-    </script>
   </body>
 </html>

+ 1 - 1
jest.config.functional.ts → jest.config.functional.chrome.ts

@@ -1,4 +1,4 @@
 export default {
   testMatch: ['**/tests/functional/**/*.ts'],
-  preset: './tests/jest.ts-puppeteer.preset.ts',
+  preset: './tests/jest.ts-puppeteer.preset.chrome.ts',
 };

+ 4 - 0
jest.config.functional.firefox.ts

@@ -0,0 +1,4 @@
+export default {
+  testMatch: ['**/tests/functional/**/*.ts'],
+  preset: './tests/jest.ts-puppeteer.preset.firefox.ts',
+};

文件差異過大導致無法顯示
+ 297 - 282
package-lock.json


+ 7 - 4
package.json

@@ -15,16 +15,19 @@
   "scripts": {
     "build": "npm run build:esbuild && npm run build:prettier:write && npm run build:examples",
     "build:esbuild": "node ./esbuild.js",
-    "build:esbuild:watch": "node ./esbuild.js",
-    "build:examples:branch": "bash build/examples-branch.sh",
+    "build:esbuild:watch": "node ./esbuild.js watch",
     "build:examples": "bash build/examples.sh",
+    "build:examples:branch": "bash build/examples-branch.sh",
     "build:prettier:check": "prettier -c .",
     "build:prettier:write": "prettier -w .",
     "build:watch": "npm run build:esbuild:watch",
     "terser": "terser",
     "test": "npm run test:unit && npm run test:functional",
-    "test:functional": "jest -c jest.config.functional.ts",
-    "test:unit": "jest -c jest.config.unit.ts"
+    "test:functional": "npm run test:functional:chrome && npm run test:functional:firefox",
+    "test:functional:chrome": "jest -c jest.config.functional.chrome.ts",
+    "test:functional:firefox": "jest -c jest.config.functional.firefox.ts",
+    "test:unit": "jest -c jest.config.unit.ts",
+    "watch": "npm run build:watch"
   },
   "devDependencies": {
     "@types/expect-puppeteer": "^4.4.7",

+ 4 - 0
src/lib/DAV/Collection.ts

@@ -95,6 +95,10 @@ export default class Collection extends EventObject {
         return;
       }
 
+      if (entry.directory && !destinationFullPath.endsWith('/')) {
+        destinationFullPath += '/';
+      }
+
       const newEntry = new Entry({
         directory: entry.directory,
         fullPath: destinationFullPath,

+ 54 - 46
src/lib/DAV/Entry.ts

@@ -1,6 +1,6 @@
+import Collection from './Collection';
 import EventObject from '../EventObject';
 import joinPath from '../joinPath';
-import Collection from './Collection';
 
 type EntryArgs = {
   directory?: boolean;
@@ -16,22 +16,22 @@ type EntryArgs = {
 };
 
 export default class Entry extends EventObject {
-  #del;
-  #directory;
-  #displaySize;
-  #extension;
-  #fullPath;
-  #mimeType;
-  #modified;
-  #name;
-  #path;
-  #placeholder;
-  #rename;
-  #size;
-  #title;
-  #type;
-
-  collection;
+  #del: boolean;
+  #directory: boolean;
+  #displaySize: string;
+  #extension: string;
+  #fullPath: string;
+  #mimeType: string;
+  #modified: Date;
+  #name: string;
+  #path: string;
+  #placeholder: boolean;
+  #rename: boolean;
+  #size: number;
+  #title: string;
+  #type: string;
+
+  collection: Collection | null;
 
   constructor({
     directory = false,
@@ -47,9 +47,12 @@ export default class Entry extends EventObject {
   }: EntryArgs) {
     super();
 
+    const [path, name] = this.getFilename(fullPath);
+
+    this.#path = path;
+    this.#name = name;
     this.#directory = directory;
     this.#fullPath = fullPath;
-    [this.#path, this.#name] = this.getFilename();
     this.#title = title;
     this.#modified = modified;
     this.#size = size;
@@ -60,7 +63,7 @@ export default class Entry extends EventObject {
     this.collection = collection;
   }
 
-  createParentEntry() {
+  createParentEntry(): Entry {
     return this.update({
       fullPath: this.path,
       title: '&larr;',
@@ -69,14 +72,14 @@ export default class Entry extends EventObject {
     });
   }
 
-  getFilename(path = this.#fullPath) {
-    path = joinPath(path).split(/\//);
-    const file = path.pop();
+  getFilename(path: string): [string, string] {
+    const pathParts = joinPath(path).split(/\//),
+      file = pathParts.pop();
 
-    return [joinPath(...path), file];
+    return [joinPath(...pathParts), file];
   }
 
-  update(properties: EntryArgs = {}) {
+  update(properties: EntryArgs = {}): Entry {
     return new Entry({
       ...{
         directory: this.directory,
@@ -92,35 +95,40 @@ export default class Entry extends EventObject {
     });
   }
 
-  get del() {
+  get del(): boolean {
     return this.#del;
   }
 
-  get directory() {
+  get directory(): boolean {
     return this.#directory;
   }
 
-  get displaySize() {
+  get displaySize(): string {
     if (this.directory) {
       return '';
     }
 
     if (!this.#displaySize) {
       this.#displaySize = ['bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'].reduce(
-        (size, label) =>
-          typeof size === 'string'
-            ? size
-            : size < 1024
-            ? `${size.toFixed(2 * (label === 'bytes' ? 0 : 1))} ${label}`
-            : size / 1024,
+        (size: string | number, label) => {
+          if (typeof size === 'string') {
+            return size;
+          }
+
+          if (size < 1024) {
+            return `${size.toFixed(2 * (label === 'bytes' ? 0 : 1))} ${label}`;
+          }
+
+          return size / 1024;
+        },
         this.#size
-      );
+      ) as string;
     }
 
     return this.#displaySize;
   }
 
-  get extension() {
+  get extension(): string {
     if (this.directory) {
       return '';
     }
@@ -132,45 +140,45 @@ export default class Entry extends EventObject {
     return this.#extension;
   }
 
-  get fullPath() {
+  get fullPath(): string {
     return this.#fullPath;
   }
 
-  get mimeType() {
+  get mimeType(): string {
     return this.#mimeType;
   }
 
-  get modified() {
+  get modified(): Date {
     return this.#modified;
   }
 
-  get name() {
+  get name(): string {
     return this.#name;
   }
 
-  get path() {
+  get path(): string {
     return this.#path;
   }
 
-  get placeholder() {
+  get placeholder(): boolean {
     return this.#placeholder;
   }
 
-  set placeholder(value) {
+  set placeholder(value: boolean) {
     this.#placeholder = value;
 
     this.trigger('entry:update', this);
   }
 
-  get rename() {
+  get rename(): boolean {
     return this.#rename;
   }
 
-  get size() {
+  get size(): number {
     return this.#size;
   }
 
-  get title() {
+  get title(): string {
     if (!this.#title) {
       this.#title = decodeURIComponent(this.#name);
     }
@@ -178,7 +186,7 @@ export default class Entry extends EventObject {
     return this.#title;
   }
 
-  get type() {
+  get type(): string {
     if (!this.#type) {
       let type;
 

+ 14 - 10
src/lib/DAV/Response.ts

@@ -5,15 +5,15 @@ export default class Response {
   #document;
   #parser;
 
-  #getTag = (doc, tag) => doc.querySelector(tag);
+  #getTag = (doc: Element, tag: string) => doc.getElementsByTagName(tag)[0];
 
-  #getTagContent = (doc, tag) => {
+  #getTagContent = (doc: Element, tag) => {
     const node = this.#getTag(doc, tag);
 
     return node ? node.textContent : '';
   };
 
-  constructor(rawDocument, parser = new DOMParser()) {
+  constructor(rawDocument: string, parser: DOMParser = new DOMParser()) {
     this.#parser = parser;
     this.#document = parser.parseFromString(rawDocument, 'application/xml');
   }
@@ -21,7 +21,9 @@ export default class Response {
   collection({ sortDirectoriesFirst = false } = {}) {
     if (!this.#collection) {
       this.#collection = new Collection(
-        this.responseToPrimitives(this.#document.querySelectorAll('response')),
+        this.responseToPrimitives(
+          this.#document.getElementsByTagName('D:response')
+        ),
         {
           sortDirectoriesFirst,
         }
@@ -31,13 +33,15 @@ export default class Response {
     return this.#collection;
   }
 
-  responseToPrimitives(responses) {
+  responseToPrimitives(responses: HTMLCollection) {
     return Array.from(responses).map((response) => ({
-      directory: !!this.#getTag(response, 'collection'),
-      fullPath: this.#getTagContent(response, 'href'),
-      modified: Date.parse(this.#getTagContent(response, 'getlastmodified')),
-      size: parseInt(this.#getTagContent(response, 'getcontentlength'), 10),
-      mimeType: this.#getTagContent(response, 'getcontenttype'),
+      directory: !!this.#getTag(response, 'D:collection'),
+      fullPath: this.#getTagContent(response, 'D:href'),
+      modified: Date.parse(
+        this.#getTagContent(response, 'lp1:getlastmodified')
+      ),
+      size: parseInt(this.#getTagContent(response, 'lp1:getcontentlength'), 10),
+      mimeType: this.#getTagContent(response, 'D:getcontenttype'),
     }));
   }
 }

+ 5 - 2
src/lib/UI/NativeDOM.ts

@@ -114,11 +114,14 @@ export default class NativeDOM extends UI {
             }`,
             ''
           );
-      if (entry.path === destinationPath) {
+
+      if (entry.path === destinationPath || entry.directory) {
         return new Melba({
           content: `'${
             entry.title
-          }' successfully renamed to '${decodeURIComponent(destinationFile)}'.`,
+          }' successfully renamed to '${decodeURIComponent(
+            destinationFile || destinationPath
+          )}'.`,
           type: 'success',
           hide: 5,
         });

+ 4 - 6
src/lib/UI/NativeDOM/List.ts

@@ -30,8 +30,7 @@ export default class List extends Element {
     });
 
     const arrowHandler = (event) => {
-      if (![38, 40].includes(event.which)) {
-        // if (! ['ArrowUp', 'ArrowDown'].includes(event.key)) {
+      if (!['ArrowUp', 'ArrowDown'].includes(event.key)) {
         return;
       }
 
@@ -45,11 +44,10 @@ export default class List extends Element {
           ? current.nextSibling
           : this.element.querySelector('li:first-child'),
         previous = current ? current.previousSibling : null;
-      if (event.which === 38 && previous) {
-        // if (event.key === 'ArrowUp' && previous) {
+
+      if (event.key === 'ArrowUp' && previous) {
         previous.focus();
-      } else if (event.which === 40 && next) {
-        // else if (event.key === 'ArrowDown' && next) {
+      } else if (event.key === 'ArrowDown' && next) {
         next.focus();
       }
     };

+ 13 - 24
src/lib/UI/NativeDOM/List/Item.ts

@@ -41,7 +41,7 @@ export default class Item extends Element {
   });
 
   constructor(entry, base64Encoder = btoa) {
-    super(`<li tabindex="0" data-full-path=${entry.fullPath}">
+    super(`<li tabindex="0" data-full-path="${entry.fullPath}">
   <span class="title">${entry.title}</span>
   <input type="text" name="rename" class="hidden" readonly>
   <span class="size">${entry.displaySize}</span>
@@ -117,22 +117,14 @@ export default class Item extends Element {
     });
 
     element.addEventListener('keydown', (event) => {
-      if ([113, 46, 13].includes(event.which)) {
-        // if (['F2', 'Delete', 'Enter'].includes(event.key)) {
+      if (['F2', 'Delete', 'Enter'].includes(event.key)) {
         event.preventDefault();
 
-        if (event.which === 113) {
-          // if (event.key === 'F2') {
-          if (this.#entry.rename) {
-            this.rename();
-          }
-        } else if (event.which === 46) {
-          // else if (event.key === 'Delete') {
-          if (this.#entry.del) {
-            this.del();
-          }
-        } else if (event.which === 13 && !this.#entry.directory) {
-          // else if (event.key === 'Enter' && ! this.#entry.directory) {
+        if (event.key === 'F2' && this.#entry.rename) {
+          this.rename();
+        } else if (event.key === 'Delete' && this.#entry.del) {
+          this.del();
+        } else if (event.key === 'Enter' && !this.#entry.directory) {
           if (event.shiftKey) {
             return this.download();
           }
@@ -182,15 +174,14 @@ export default class Item extends Element {
     this.loading();
 
     if (entry.directory) {
-      return this.trigger('go', entry.fullPath, {
-        failure: () => this.loading(false),
-      });
+      return this.trigger('go', entry.fullPath, false, () =>
+        this.loading(false)
+      );
     }
 
     const launchLightbox = (lightboxContent, onShow = null) => {
       const escapeListener = (event) => {
-          if (event.which === 27) {
-            // if (event.key === 'Escape') {
+          if (event.key === 'Escape') {
             lightbox.close();
           }
         },
@@ -289,14 +280,12 @@ export default class Item extends Element {
         save();
       },
       keyDownListener = (event) => {
-        if (event.which === 13) {
-          // if (event.key === 'Enter') {
+        if (event.key === 'Enter') {
           event.stopPropagation();
           event.preventDefault();
 
           save();
-        } else if (event.which === 27) {
-          // else if (event.key === 'Escape') {
+        } else if (event.key === 'Escape') {
           revert();
         }
       },

+ 1 - 1
src/lib/UI/UI.ts

@@ -38,7 +38,7 @@ export default class UI extends EventObject {
     };
   }
 
-  get dav() {
+  get dav(): DAV {
     return this.#dav;
   }
 

+ 3 - 4
src/lib/joinPath.ts

@@ -1,10 +1,9 @@
-export const trimSlashes = (piece) => piece.replace(/^\/+|\/+$/g, '');
+export const trimSlashes = (piece: string) => piece.replace(/^\/+|\/+$/g, '');
 
-export const joinPath = (...pieces) => {
-  return `/${pieces
+export const joinPath = (...pieces: string[]) =>
+  `/${pieces
     .map(trimSlashes)
     .filter((piece) => piece)
     .join('/')}`;
-};
 
 export default joinPath;

文件差異過大導致無法顯示
+ 0 - 0
src/webdav-min.js


+ 252 - 0
tests/functional/List.test.ts

@@ -0,0 +1,252 @@
+import {
+  isLightboxClosed,
+  isPageReady,
+  isLightboxShown,
+  expectToastShown,
+  isElementGone,
+  isElementThere,
+} from '../lib/isReady';
+import { ElementHandle } from 'puppeteer';
+import * as fs from 'fs';
+
+const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080/',
+  DESTINATION_FONT_FILE = '/tmp/BlackAndWhitePicture-Regular.ttf';
+
+describe('WebDAV.js', () => {
+  describe('List', () => {
+    it('should be possible to preview items', async () => {
+      // Wait for page JS to replace page contents
+      await isPageReady(page, BASE_URL);
+
+      await (
+        [
+          [
+            '[data-full-path="/0.jpg"]',
+            async (lightbox: ElementHandle) => {
+              await expect(await lightbox.$('img')).toBeTruthy();
+            },
+          ],
+          [
+            '[data-full-path="/BlackAndWhitePicture-Regular.ttf"]',
+            async (lightbox: ElementHandle) => {
+              await expect(await lightbox.$('img')).toBeFalsy();
+              await expect(await lightbox.$$('style')).toHaveLength(1);
+              await expect(await lightbox.$$('h1')).toHaveLength(1);
+              await expect(await lightbox.$$('p')).toHaveLength(4);
+            },
+          ],
+          [
+            '[data-full-path="/dummy.pdf"]',
+            async (lightbox: ElementHandle) => {
+              await expect(await lightbox.$('iframe')).toBeTruthy();
+            },
+          ],
+          [
+            '[data-full-path="/style.css"]',
+            async (lightbox: ElementHandle) => {
+              await expect(await lightbox.$('pre.language-css')).toBeTruthy();
+            },
+          ],
+          [
+            '[data-full-path="/video.mp4"]',
+            async (lightbox: ElementHandle) => {
+              await expect(await lightbox.$('video[autoplay]')).toBeTruthy();
+            },
+          ],
+        ] as [string, (lightbox: ElementHandle) => Promise<void>][]
+      ).reduce(
+        async (
+          previous: Promise<void>,
+          [selector, expectation]
+        ): Promise<void> => {
+          await previous;
+
+          await page.click(selector);
+
+          await expectation(await isLightboxShown(page));
+
+          await isLightboxClosed(page);
+
+          await page.waitForTimeout(500);
+        },
+        Promise.resolve(null)
+      );
+    });
+
+    it('should be possible to navigate directories', async () => {
+      await isPageReady(page, BASE_URL);
+
+      await expect(await page.$$('main ul li')).toHaveLength(23);
+
+      await page.click('[data-full-path="/source.js/"]');
+
+      await page.waitForTimeout(200);
+
+      await expect(await page.$$('main ul li')).toHaveLength(1);
+
+      await expect(await page.$('[data-full-path="/"]')).toBeTruthy();
+
+      await page.click('[data-full-path="/"]');
+
+      await page.waitForTimeout(200);
+
+      await expect(await page.$$('main ul li')).toHaveLength(23);
+    });
+
+    it('should be possible to create a new navigable directory, rename it and delete it', async () => {
+      await isPageReady(page, BASE_URL);
+
+      page.once(
+        'dialog',
+        async (dialog) => await dialog.accept('new-directory')
+      );
+
+      await page.click('.create-directory');
+
+      await isElementThere(page, '[data-full-path="/new-directory/"]');
+
+      await expectToastShown(
+        page,
+        `'new-directory' has been created.`,
+        'success'
+      );
+
+      await page.click('[data-full-path="/new-directory/"] .rename');
+
+      await page.waitForFunction(() =>
+        document.activeElement.matches(
+          '[data-full-path="/new-directory/"] input[type="text"]'
+        )
+      );
+
+      await page.keyboard.down('Control');
+      await page.keyboard.press('Backspace');
+      await page.keyboard.up('Control');
+
+      await page.type(
+        '[data-full-path="/new-directory/"] input[type="text"]',
+        'folder'
+      );
+
+      await page.keyboard.press('Enter');
+
+      await expectToastShown(
+        page,
+        `'new-directory' successfully renamed to 'new-folder'.`,
+        'success'
+      );
+
+      await isElementThere(page, '[data-full-path="/new-folder/"]');
+
+      page.once('dialog', async (dialog) => await dialog.accept());
+
+      await page.click('[data-full-path="/new-folder/"] .delete');
+
+      await isElementGone(page, '[data-full-path="/new-folder/"]');
+
+      await expectToastShown(page, `'new-folder' has been deleted.`, 'success');
+    });
+
+    it('should show expected errors', async () => {
+      await isPageReady(page, BASE_URL);
+
+      await page.click('[data-full-path="/inaccessible-dir/"]');
+
+      await expectToastShown(
+        page,
+        'HEAD /inaccessible-dir/ failed: Forbidden (403)',
+        'error'
+      );
+
+      await page.click('[data-full-path="/inaccessible-file"]');
+
+      await expectToastShown(
+        page,
+        'GET /inaccessible-file failed: Forbidden (403)',
+        'error'
+      );
+
+      await page.click('[data-full-path="/inaccessible-image.jpg"]');
+
+      await expectToastShown(
+        page,
+        'HEAD /inaccessible-image.jpg failed: Forbidden (403)',
+        'error'
+      );
+
+      await page.click('[data-full-path="/inaccessible-text-file.txt"]');
+
+      await expectToastShown(
+        page,
+        'GET /inaccessible-text-file.txt failed: Forbidden (403)',
+        'error'
+      );
+    });
+
+    it('should be possible to download a file', async () => {
+      await isPageReady(page, BASE_URL);
+
+      await expect(() => fs.accessSync(DESTINATION_FONT_FILE)).toThrow();
+
+      await page
+        .target()
+        .createCDPSession()
+        .then((client) =>
+          client.send('Page.setDownloadBehavior', {
+            behavior: 'allow',
+            downloadPath: '/tmp',
+          })
+        );
+
+      await page.click(
+        '[data-full-path="/BlackAndWhitePicture-Regular.ttf"] [download]'
+      );
+
+      // wait for the file to download
+      await page.waitForTimeout(400);
+
+      await expect(() => fs.accessSync(DESTINATION_FONT_FILE)).not.toThrow();
+
+      fs.rmSync(DESTINATION_FONT_FILE);
+    });
+
+    it('should be possible to upload a file', async () => {
+      await isPageReady(page, BASE_URL);
+
+      const elementHandle = (await page.$(
+        'input[type=file]'
+      )) as ElementHandle<HTMLInputElement>;
+
+      await elementHandle.uploadFile('./package.json');
+
+      await expectToastShown(
+        page,
+        `'package.json' has been successfully uploaded.`,
+        'success'
+      );
+
+      await page.click('[data-full-path="/package.json"]');
+
+      await page.once('dialog', async (dialog) => await dialog.accept());
+
+      await page.click('[data-full-path="/package.json"] .delete');
+
+      await isElementGone(page, '[data-full-path="/package.json"]');
+
+      await expectToastShown(
+        page,
+        `'package.json' has been deleted.`,
+        'success'
+      );
+    });
+  });
+
+  beforeAll(async () => {
+    try {
+      fs.accessSync(DESTINATION_FONT_FILE);
+      fs.rmSync(DESTINATION_FONT_FILE);
+    } catch (e) {
+      // we don't need to do anything here
+    }
+  });
+});

+ 1 - 0
tests/jest.ts-puppeteer.preset.chrome.ts

@@ -0,0 +1 @@
+module.exports = require('./jest.ts-puppeteer.preset')('chrome');

+ 1 - 0
tests/jest.ts-puppeteer.preset.firefox.ts

@@ -0,0 +1 @@
+module.exports = require('./jest.ts-puppeteer.preset')('firefox');

+ 15 - 0
tests/jest.ts-puppeteer.preset.ts

@@ -0,0 +1,15 @@
+const { jsWithTs } = require('ts-jest/presets');
+const jestPuppeteerPreset = require('jest-puppeteer/jest-preset.js');
+
+const combined = {
+  ...jsWithTs,
+  ...jestPuppeteerPreset,
+};
+
+module.exports = (product) => ({
+  ...combined,
+  launch: {
+    ...(combined.launch ?? {}),
+    product,
+  },
+});

+ 38 - 0
tests/lib/getProperties.ts

@@ -0,0 +1,38 @@
+import { ElementHandle } from 'puppeteer';
+
+export const getRawProperty = async <
+  T extends HTMLElement = HTMLElement,
+  K extends keyof T = keyof T
+>(
+  element: Promise<ElementHandle<T>> | ElementHandle<T>,
+  property: K
+): Promise<T[K]> =>
+  await (await (await element)?.getProperty(property as string))?.jsonValue();
+
+export const getRawProperties = async <
+  T extends HTMLElement = HTMLElement,
+  K extends keyof T = keyof T
+>(
+  element: Promise<ElementHandle<T>> | ElementHandle<T>,
+  ...properties: K[]
+) =>
+  Promise.all(properties.map((property) => getRawProperty(element, property)));
+
+export const getRawPropertyFromMany = async <
+  T extends HTMLElement = HTMLElement,
+  K extends keyof T = keyof T
+>(
+  elements: Promise<ElementHandle<T>[]> | ElementHandle<T>[],
+  property: K
+): Promise<T[K][]> =>
+  Promise.all(
+    (await elements).map(
+      async (element): Promise<any> =>
+        (['innerText', 'innerHTML'] as K[]).includes(property)
+          ? await element?.evaluate<[string]>(
+              (element: T, property: string) => element[property],
+              property as string
+            )
+          : getRawProperty<T, K>(element, property)
+    )
+  );

+ 57 - 0
tests/lib/grantClipboardPermissions.ts

@@ -0,0 +1,57 @@
+import { Page } from 'puppeteer';
+
+export const grantRawPermissions = async (
+  page: Page,
+  permissions: string[]
+) => {
+  const context = await page.browserContext(),
+    url = new URL(page.url());
+
+  // @ts-ignore
+  await context._connection.send('Browser.grantPermissions', {
+    origin: url.origin,
+    // @ts-ignore
+    browserContextId: context._id,
+    permissions: permissions,
+  });
+
+  await page.reload();
+};
+
+// https://github.com/puppeteer/puppeteer/issues/3241#issuecomment-751489962:
+// > When I try to grant clipboard-write permissions in headless mode it changes it to 'denied' instead (if I don't call it is it 'prompt'):
+// >
+// >   context.overridePermissions(url.origin, ['clipboard-write'])
+// >
+// >   const page = await context.newPage()
+// >   await page.goto(url.origin)
+// >   const state = await page.evaluate(async () => {
+// >     return (await navigator.permissions.query({name: 'clipboard-write'})).state;
+// >   });
+// >   console.log(state) // denied
+// >
+// > macOS: 10.15.7
+// > puppeteer: 5.4.1
+//
+// I'm not an expert in this regard, but at least when limiting the scope of this discussion to clipboard-write and clipboard-read, it seems to me that there is a bug/mistake in the overridePermissions() method. As per its documentation:
+//
+// > An array of permissions to grant. All permissions that are not listed here will be automatically denied.
+//
+// So, if you specify either clipboard-write and clipboard-read as permissions then both will be mapped to clipboardReadWrite which is the only permission that will be granted. By looking at the Chrome DevTools Protocol (which overridePermissions() uses to override permissions) I found that there are actually two permissions related to the clipboard: clipboardSanitizedWrite and clipboardReadWrite. When I mimicked overridePermissions()'s CDP call manually using 'clipboardSanitizedWrite' as permissions I was able to use navigator.clipboard.writeText and your snipped returned "granted" (instead of "denied"). More specifically, the following snippet should work:
+//
+//   await context._connection.send('Browser.grantPermissions', {
+//     origin: url.origin,
+//     browserContextId: this._id || undefined,
+//     permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
+//   });
+//
+//   const page = await context.newPage()
+//   await page.goto(url.origin)
+//   const state = await page.evaluate(async () => {
+//     return (await navigator.permissions.query({name: 'clipboard-write'})).state;
+//   });
+//   console.log(state) // granted
+//
+// It's not pretty and should probably be fixed in overridePermissions(), but it gets the job done.
+export const grantClipboardPermissions = (page: Page) =>
+  grantRawPermissions(page, ['clipboardReadWrite', 'clipboardSanitizedWrite']);

+ 64 - 0
tests/lib/isReady.ts

@@ -0,0 +1,64 @@
+import { Page } from 'puppeteer';
+
+export const isPageReady = async (page: Page, url: string) => {
+  await page.goto(url);
+
+  await isElementThere(page, 'main ul li');
+};
+
+export const isElementThere = async (page: Page, selector: string) =>
+  await page.waitForFunction(
+    (selector) => !!document.querySelector(selector),
+    {},
+    selector
+  );
+
+export const isElementGone = async (page: Page, selector: string) =>
+  await page.waitForFunction(
+    (selector) => !document.querySelector(selector),
+    {},
+    selector
+  );
+
+export const isLightboxShown = async (page: Page) => {
+  await isElementThere(page, '.basicLightbox--visible');
+
+  return page.$('.basicLightbox--visible');
+};
+
+export const isLightboxClosed = async (page: Page) => {
+  const lightbox = await isLightboxShown(page);
+
+  // Click on the overlay to close the lightbox
+  await lightbox.click({
+    offset: {
+      x: 10,
+      y: 10,
+    },
+  });
+
+  await isElementGone(page, '.basicLightbox--visible');
+};
+
+export const expectToastShown = async (
+  page: Page,
+  text: string,
+  type: string
+) => {
+  await page.waitForTimeout(100);
+
+  const toast = await page.$('.toast__container .toast');
+
+  await expect(
+    await toast.evaluate((toast) => toast.childNodes[1].textContent)
+  ).toEqual(text);
+
+  await expect(
+    await toast.evaluate(
+      (toast, type: string) => toast.classList.contains(`toast--${type}`),
+      type
+    )
+  ).toBeTruthy();
+
+  await toast.evaluate((toast) => toast.remove());
+};

+ 67 - 39
tests/unit/DAV.test.ts

@@ -1,42 +1,60 @@
 import Collection from '../../src/lib/DAV/Collection';
 import DAV from '../../src/lib/DAV';
+import { DOMParser } from '@xmldom/xmldom';
 import HTTP from '../../src/lib/HTTP';
 
-// @ts-ignore
-const MockHttp = jest.createMockFromModule('../../src/lib/HTTP').default;
-
 describe('DAV', () => {
-  // @ts-ignore
-  console.log((MockHttp as HTTP).GET('', {}));
-
-  const getSpies = (SpyHTTPReturns = {}, SpyCacheReturns = {}) => {
-    const SpyHTTP = jasmine.createSpyObj('HTTP', [
-        'GET',
-        'HEAD',
-        'PUT',
-        'PROPFIND',
-        'DELETE',
-        'MKCOL',
-        'COPY',
-        'MOVE',
-      ]),
-      SpyCache = jasmine.createSpyObj('Cache', ['delete', 'get', 'has', 'set']);
+  const getSpies = (
+    SpyHTTPReturns = {},
+    SpyCacheReturns = {}
+  ): [HTTP, Map<string, Collection>] => {
+    const SpyHTTP = new HTTP(),
+      SpyCache = new Map();
+
     [
-      [SpyHTTP, SpyHTTPReturns],
-      [SpyCache, SpyCacheReturns],
-    ].forEach(([object, returnValues]) =>
-      Object.entries(returnValues).forEach(
-        ([method, returnValue]) =>
-          (object[method] = object[method].and.returnValue(returnValue))
-      )
+      'GET',
+      'HEAD',
+      'PUT',
+      'PROPFIND',
+      'DELETE',
+      'MKCOL',
+      'COPY',
+      'MOVE',
+    ].forEach(
+      (methodName) =>
+        (SpyHTTP[methodName] = jest.fn(
+          () =>
+            new Promise((resolve) =>
+              resolve(SpyHTTPReturns[methodName] ?? null)
+            )
+        ))
+    );
+
+    ['delete', 'get', 'has', 'set'].forEach(
+      (methodName) =>
+        (SpyCache[methodName] = jest.fn(
+          () => SpyCacheReturns[methodName] ?? null
+        ))
     );
 
     return [SpyHTTP, SpyCache];
   };
 
+  if (typeof window === 'undefined') {
+    global.location = {
+      ...global.location,
+      protocol: 'http:',
+      host: 'localhost',
+      hostname: 'localhost',
+    };
+
+    global.DOMParser = DOMParser;
+  }
+
   it('should fire a HEAD request on check', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+
     dav.check('/checkHeadRequest');
     expect(SpyHTTP.HEAD).toHaveBeenCalledWith('/checkHeadRequest');
   });
@@ -44,6 +62,7 @@ describe('DAV', () => {
   it('should fire a COPY request on copy', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+
     dav.copy('/copySource', '/copyDestination');
     expect(SpyHTTP.COPY).toHaveBeenCalledWith('/copySource', {
       headers: {
@@ -57,6 +76,7 @@ describe('DAV', () => {
   it('should fire a DELETE request on del', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+
     dav.del('/checkDeleteRequest');
     expect(SpyHTTP.DELETE).toHaveBeenCalledWith('/checkDeleteRequest');
   });
@@ -64,6 +84,7 @@ describe('DAV', () => {
   it('should fire a GET request on get', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+
     dav.get('/checkGetRequest');
     expect(SpyHTTP.GET).toHaveBeenCalledWith('/checkGetRequest');
   });
@@ -85,6 +106,7 @@ describe('DAV', () => {
       ),
       dav = new DAV({}, SpyCache, SpyHTTP),
       collection = await dav.list('/checkPropfindRequest');
+
     expect(SpyCache.get).toHaveBeenCalledWith('/checkPropfindRequest/');
     expect(SpyHTTP.HEAD).toHaveBeenCalledWith('/checkPropfindRequest/');
     expect(SpyHTTP.PROPFIND).toHaveBeenCalledWith('/checkPropfindRequest/');
@@ -98,6 +120,8 @@ describe('DAV', () => {
   it('should fire an MKCOL request on mkcol', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+    0;
+
     dav.mkcol('/checkMkcolRequest');
     expect(SpyHTTP.MKCOL).toHaveBeenCalledWith('/checkMkcolRequest');
   });
@@ -105,6 +129,7 @@ describe('DAV', () => {
   it('should fire a MOVE request on move', () => {
     const [SpyHTTP, SpyCache] = getSpies(),
       dav = new DAV({}, SpyCache, SpyHTTP);
+
     dav.move('/moveSource', '/moveDestination');
 
     expect(SpyHTTP.MOVE).toHaveBeenCalledWith('/moveSource', {
@@ -116,20 +141,22 @@ describe('DAV', () => {
     });
   });
 
-  it('should fire a PUT request on upload', () => {
-    const [SpyHTTP, SpyCache] = getSpies(),
-      dav = new DAV({}, SpyCache, SpyHTTP),
-      file = new File([''], 'uploadTest', {
-        type: 'text/plain',
-      });
-    dav.upload('/path/', file);
-    expect(SpyHTTP.PUT).toHaveBeenCalledWith('/path/uploadTest', {
-      headers: {
-        'Content-Type': file.type,
-      },
-      body: file,
-    });
-  });
+  test.todo('should fire a PUT request on upload - functional only');
+  // it('should fire a PUT request on upload', () => {
+  //   const [SpyHTTP, SpyCache] = getSpies(),
+  //     dav = new DAV({}, SpyCache, SpyHTTP),
+  //     file = new File([''], 'uploadTest', {
+  //       type: 'text/plain',
+  //     });
+  //
+  //   dav.upload('/path/', file);
+  //   expect(SpyHTTP.PUT).toHaveBeenCalledWith('/path/uploadTest', {
+  //     headers: {
+  //       'Content-Type': file.type,
+  //     },
+  //     body: file,
+  //   });
+  // });
 
   it('should not fire a HEAD request on list when `bypassCheck` is set', async () => {
     const [SpyHTTP, SpyCache] = getSpies(
@@ -153,6 +180,7 @@ describe('DAV', () => {
         SpyCache,
         SpyHTTP
       );
+
     await dav.list('/checkPropfindRequest');
 
     expect(SpyHTTP.HEAD).not.toHaveBeenCalledWith('/checkPropfindRequest/');

+ 4 - 2
tests/unit/DAV/Entry.test.ts

@@ -7,14 +7,12 @@ describe('Entry', () => {
       modified: new Date(),
     }),
     file = new Entry({
-      directory: false,
       fullPath: '/path/to/file.txt',
       modified: new Date(),
       size: 54321,
       mimeType: 'text/plain',
     }),
     atFile = new Entry({
-      directory: false,
       fullPath: '/%40',
       modified: new Date(),
       size: 54321,
@@ -26,6 +24,10 @@ describe('Entry', () => {
     expect(directory.name).toBe('to');
   });
 
+  it('should return an empty size for directories', () => {
+    expect(directory.directory).toBe(true);
+  });
+
   it('should return an empty size for directories', () => {
     expect(directory.displaySize).toBe('');
   });

文件差異過大導致無法顯示
+ 0 - 0
tests/unit/DAV/Response.test.ts


部分文件因文件數量過多而無法顯示