add cypress tests

This commit is contained in:
MxMarx 2023-10-21 21:47:04 -07:00
parent 0cbcfbd273
commit e8432d9a0d
34 changed files with 13497 additions and 56 deletions

View file

@ -11,5 +11,10 @@ RUN apt-get update && \
curl tzdata ffmpeg && \ curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory # Move tone executable to appropriate directory (this doesn't seem to work in devcontainer because it needs musl)
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/ # COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
RUN wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz && \
tar xzf tone-*.tar.gz && \
mv tone*/tone /usr/local/bin/

View file

@ -15,12 +15,18 @@
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume" "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
], ],
"features": {
"ghcr.io/devcontainers/features/desktop-lite:1": {
"password": "vscode"
}
},
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ "forwardPorts": [
3000, 3000,
3333 3333,
6080
], ],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh", "postCreateCommand": "sh .devcontainer/post-create.sh",

4
.gitignore vendored
View file

@ -16,3 +16,7 @@ test/
sw.* sw.*
.DS_STORE .DS_STORE
.idea/* .idea/*
audiobookshelf/config/absdatabase.sqlite
/coverage/
/.nyc_output/
/cypress/fixtures/audiobooks/

View file

@ -7,7 +7,7 @@
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p> <p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="userIsAdminOrUp" class="flex"> <div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn> <ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn> <ui-btn data-testid="ButtonScanLibrary" color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div> </div>
</div> </div>
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center"> <div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">

View file

@ -10,16 +10,16 @@
<div class="w-full p-8"> <div class="w-full p-8">
<div class="flex py-2"> <div class="flex py-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" :label="$strings.LabelUsername" /> <ui-text-input-with-label v-model="newUser.username" data-testid="newUsername" :label="$strings.LabelUsername" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" /> <ui-text-input-with-label v-if="!isEditingRoot" data-testid="newPassword" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
<ui-text-input-with-label v-else v-model="newUser.email" :label="$strings.LabelEmail" /> <ui-text-input-with-label v-else data-testid="newEmail" v-model="newUser.email" :label="$strings.LabelEmail" />
</div> </div>
</div> </div>
<div v-show="!isEditingRoot" class="flex py-2"> <div v-show="!isEditingRoot" class="flex py-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.email" :label="$strings.LabelEmail" /> <ui-text-input-with-label data-testid="newEmail" v-model="newUser.email" :label="$strings.LabelEmail" />
</div> </div>
<div class="px-2 w-52"> <div class="px-2 w-52">
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" /> <ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />

View file

@ -6,13 +6,13 @@
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" /> <ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div> </div>
<div class="w-full md:flex-grow px-1 py-1 md:py-0"> <div class="w-full md:flex-grow px-1 py-1 md:py-0">
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" /> <ui-text-input-with-label ref="nameInput" v-model="name" data-testid="LabelLibraryName" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
</div> </div>
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0"> <div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" /> <ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" />
</div> </div>
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0"> <div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelMetadataProvider" small @input="formUpdated" /> <ui-dropdown v-model="provider" data-testid="metadataProvider" :items="providers" :label="$strings.LabelMetadataProvider" small @input="formUpdated" />
</div> </div>
</div> </div>
@ -25,7 +25,7 @@
</div> </div>
<div class="flex py-1 px-2 items-center w-full"> <div class="flex py-1 px-2 items-center w-full">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" /> <ui-editable-text ref="newFolderInput" data-testid="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div> </div>
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>

View file

@ -16,7 +16,7 @@
<div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> <div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
<div class="flex justify-end"> <div class="flex justify-end">
<ui-btn @click="submit">{{ buttonText }}</ui-btn> <ui-btn data-testid="createLibrary" @click="submit">{{ buttonText }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>

View file

@ -8,7 +8,7 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-2xl sm:text-3xl">replay_10</span> <span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</div> </div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause"> <div data-testid="playPause" class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span> <span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div> </div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward"> <div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">

View file

@ -26,7 +26,7 @@
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters"> <ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-2xl">format_list_bulleted</span> <span data-testid="showChapters" class="material-icons text-2xl">format_list_bulleted</span>
</div> </div>
</ui-tooltip> </ui-tooltip>
@ -49,7 +49,7 @@
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" /> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
<div class="flex"> <div class="flex">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p data-testid="currentTimestamp" ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p> <p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="text-xs sm:text-sm text-gray-300 pt-0.5"> <p class="text-xs sm:text-sm text-gray-300 pt-0.5">

View file

@ -42,7 +42,7 @@
<div class="w-full flex justify-left"> <div class="w-full flex justify-left">
<!-- Dont show edit for non-root users --> <!-- Dont show edit for non-root users -->
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)"> <div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<button type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button> <button data-testid="ButtonUserEdit" type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
</div> </div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)"> <div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button> <button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>

View file

@ -8,7 +8,7 @@
</template> </template>
</draggable> </draggable>
<div v-if="!libraries.length" class="pb-4"> <div v-if="!libraries.length" class="pb-4">
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn> <ui-btn data-testid="ButtonAddYourFirstLibrary" @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
</div> </div>
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200"> <p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">

View file

@ -132,7 +132,10 @@ module.exports = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} },
babel: {
plugins: [['babel-plugin-istanbul', { extension: ['.js', '.vue'] }]],
},
}, },
watchers: { watchers: {
webpack: { webpack: {

177
client/package-lock.json generated
View file

@ -30,6 +30,7 @@
"@nuxtjs/pwa": "^3.3.5", "@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1", "@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"babel-plugin-istanbul": "^6.1.1",
"postcss": "^8.3.6", "postcss": "^8.3.6",
"tailwindcss": "^3.1.4" "tailwindcss": "^3.1.4"
} }
@ -1701,6 +1702,31 @@
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
"integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="
}, },
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"dependencies": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@ -4168,6 +4194,22 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/babel-plugin-istanbul": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
"integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
"dev": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.0.0",
"@istanbuljs/load-nyc-config": "^1.0.0",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-instrument": "^5.0.4",
"test-exclude": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/babel-plugin-polyfill-corejs2": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
@ -7286,6 +7328,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-port-please": { "node_modules/get-port-please": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-2.6.1.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-2.6.1.tgz",
@ -8744,6 +8795,40 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
"integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
"integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
"dev": true,
"dependencies": {
"@babel/core": "^7.12.3",
"@babel/parser": "^7.14.7",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-coverage": "^3.2.0",
"semver": "^6.3.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/jest-worker": { "node_modules/jest-worker": {
"version": "26.6.2", "version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
@ -15207,6 +15292,20 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=8"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -18512,6 +18611,25 @@
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
"integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="
}, },
"@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"requires": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
}
},
"@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true
},
"@jridgewell/gen-mapping": { "@jridgewell/gen-mapping": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
@ -20612,6 +20730,19 @@
} }
} }
}, },
"babel-plugin-istanbul": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
"integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
"dev": true,
"requires": {
"@babel/helper-plugin-utils": "^7.0.0",
"@istanbuljs/load-nyc-config": "^1.0.0",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-instrument": "^5.0.4",
"test-exclude": "^6.0.0"
}
},
"babel-plugin-polyfill-corejs2": { "babel-plugin-polyfill-corejs2": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
@ -23017,6 +23148,12 @@
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
} }
}, },
"get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true
},
"get-port-please": { "get-port-please": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-2.6.1.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-2.6.1.tgz",
@ -24077,6 +24214,33 @@
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="
}, },
"istanbul-lib-coverage": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
"integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
"dev": true
},
"istanbul-lib-instrument": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
"integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
"dev": true,
"requires": {
"@babel/core": "^7.12.3",
"@babel/parser": "^7.14.7",
"@istanbuljs/schema": "^0.1.2",
"istanbul-lib-coverage": "^3.2.0",
"semver": "^6.3.0"
},
"dependencies": {
"semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true
}
}
},
"jest-worker": { "jest-worker": {
"version": "26.6.2", "version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
@ -29086,6 +29250,17 @@
} }
} }
}, },
"test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
"dev": true,
"requires": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
}
},
"text-table": { "text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -30753,4 +30928,4 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
} }
} }
} }

View file

@ -34,7 +34,8 @@
"@nuxtjs/pwa": "^3.3.5", "@nuxtjs/pwa": "^3.3.5",
"@nuxtjs/tailwindcss": "^4.2.1", "@nuxtjs/tailwindcss": "^4.2.1",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"babel-plugin-istanbul": "^6.1.1",
"postcss": "^8.3.6", "postcss": "^8.3.6",
"tailwindcss": "^3.1.4" "tailwindcss": "^3.1.4"
} }
} }

View file

@ -83,7 +83,7 @@
<!-- Icon buttons --> <!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4"> <div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem"> <ui-btn v-if="showPlayButton" data-testid="playButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span> <span v-show="!isStreaming" class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }} {{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn> </ui-btn>

View file

@ -10,9 +10,9 @@
<form @submit.prevent="submitServerSetup"> <form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p> <p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="newRoot.username" label="Username" data-testid="username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="newRoot.password" label="Password" data-testid="password" type="Password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" data-testid="confirm-password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p> <p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
@ -29,10 +29,10 @@
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" /> <ui-text-input data-testid="username" v-model="username" :disabled="processing" class="mb-3 w-full" />
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" /> <ui-text-input data-testid="password" v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end py-3"> <div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div> </div>

31
cypress.config.js Normal file
View file

@ -0,0 +1,31 @@
const { defineConfig } = require("cypress");
module.exports = defineConfig({
defaultCommandTimeout: 4000,
e2e: {
baseUrl: 'http://localhost:3333',
excludeSpecPattern: ["./cypress/e2e/00-initializeDatabaseWithGui.cy.js"], // Uncomment to use API to initialize
// excludeSpecPattern: ["./cypress/e2e/00-initializeDatabaseWithApi.cy.js"], // Uncomment to use GUI to initialize
screenshotOnRunFailure: false,
setupNodeEvents(on, config) {
require('@cypress/code-coverage/task')(on, config)
on('file:preprocessor', require('@cypress/code-coverage/use-babelrc'))
return config
},
},
env: {
ADMIN_USERNAME: 'root',
ADMIN_PASSWORD: 'password',
USER_USERNAME: 'user123',
USER_PASSWORD: 'user123',
AUDIOBOOK_LIBRARY_NAME: 'Audiobooks',
AUDIOBOOK_LIBRARY_PATH: './cypress/fixtures/audiobooks',
"codeCoverage": {
"url": "http://localhost:3333/__coverage__",
}
},
retries: {
runMode: 2,
openMode: 0,
},
});

View file

@ -0,0 +1,121 @@
/**
* This spec is more of a procedure to seed a fresh database, meant to be run before other tests.
* It uses the ui to create the administrator account, add a first library, and start a scan.
*/
describe('Initialize a fresh database', () => {
let token, libraryID
// Only run these tests if the database isn't initialized
before(function () {
cy.request('/status').then((response) => {
cy.skipIf(response.body.isInit)
})
})
it('Initialize the server and root user', () => {
cy.request({
url: '/init',
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: {
newRoot: {
"username": Cypress.env('ADMIN_USERNAME'),
"password": Cypress.env('ADMIN_PASSWORD')
}
}
}).then((response) => {
expect(response.status).to.eq(200)
})
})
it('Save the root user token to fixtures/token.json', () => {
cy.loginWithApi().then((adminToken) => {
cy.readFile("cypress/fixtures/token.json").then((data) => {
token = data.bearerToken = adminToken
cy.writeFile("./cypress/fixtures/token.json", JSON.stringify(data))
})
})
})
it('Set log level to warn', () => {
cy.request({
url: '/api/settings',
method: 'PATCH',
headers: { Authorization: 'Bearer ' + token },
body: { "logLevel": 3 }
}).then((response) => {
expect(response.status).to.eq(200)
})
})
it('Create a non-admin user account', () => {
cy.request({
url: '/api/users',
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: {
"username": Cypress.env('USER_USERNAME'),
"password": Cypress.env('USER_PASSWORD'),
"type": "user"
}
}).then((response) => {
expect(response.status).to.eq(200)
})
})
it('Create a library', () => {
cy.request({
url: '/api/libraries',
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: {
"name": Cypress.env('AUDIOBOOK_LIBRARY_NAME'),
"folders": [{ "fullPath": Cypress.env('AUDIOBOOK_LIBRARY_PATH') }],
"provider": "audible"
}
}).then((response) => {
expect(response.status).to.eq(200)
expect(response.body).to.have.property("id")
libraryID = response.body.id
})
})
it('Scan the library', () => {
cy.request({
url: `/api/libraries/${libraryID}/scan`,
method: 'POST',
headers: { Authorization: 'Bearer ' + token }
}).then((response) => {
expect(response.status).to.eq(200)
})
// Wait for the scan to complete
cy.then(function checkIfScanFinished(retries = 0) {
cy.request({
url: `/api/libraries/${libraryID}`,
headers: { Authorization: 'Bearer ' + token }
}).then((response) => {
expect(response.status).to.eq(200)
cy.log('Waiting for library scan to finish, attempt: ' + retries)
if (response.body.lastScan !== null)
return
else if (retries > 10)
throw new Error("Library scan didn't complete!")
else {
retries++
cy.wait(1000).then(() => checkIfScanFinished(retries))
}
})
})
})
})

View file

@ -0,0 +1,113 @@
/**
* This spec is more of a procedure to seed a fresh database, meant to be run before other tests.
* It uses the ui to create the administrator account, add a first library, and start a scan.
*/
describe('Initialize a fresh database', () => {
let token = ""
// Only run these tests if the database isn't initialized
before(function () {
cy.request('/status').then((response) => {
cy.skipIf(response.body.isInit)
})
})
it('Create the admin account', () => {
cy.visit('/login');
cy.get('[data-testid=username]').find('input').clear().type(Cypress.env('ADMIN_USERNAME'))
cy.get('[data-testid=password]').find('input').clear().type(Cypress.env('ADMIN_PASSWORD'))
cy.get('[data-testid=confirm-password]').find('input').clear().type(Cypress.env('ADMIN_PASSWORD'))
cy.intercept('POST', '/init').as('init')
cy.get('button[type=submit]').click()
cy.wait('@init').its('response.statusCode').should('eq', 200)
})
it('Save the admin user token to fixtures/token.json', () => {
cy.loginAsAdmin().then(() => {
cy.readFile("cypress/fixtures/token.json").then((data) => {
token = localStorage.getItem("token")
expect(token).to.exist
data.bearerToken = token
cy.writeFile("./cypress/fixtures/token.json", JSON.stringify(data))
})
})
})
it('Set log level to warn', () => {
cy.request({
url: '/api/settings',
method: 'PATCH',
headers: { Authorization: 'Bearer ' + token },
body: { "logLevel": 0 }
}).then((response) => {
expect(response.status).to.eq(200)
})
})
it('Create a non-admin user account', () => {
cy.request({
url: '/api/users',
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: {
"username": Cypress.env('USER_USERNAME'),
"password": Cypress.env('USER_PASSWORD'),
"type": "user"
}
}).then((response) => {
expect(response.status).to.eq(200)
})
})
it('Create a library', () => {
cy.loginAsAdmin()
cy.visit('/');
cy.intercept('POST', '/api/libraries').as('apiCall')
// cy.get('button').contains("add").click()
cy.get('[data-testid=ButtonAddYourFirstLibrary]').click()
cy.get(".modal").filter(':visible').within(() => {
cy.get('[data-testid=LabelLibraryName]').find('input').type(Cypress.env('AUDIOBOOK_LIBRARY_NAME'))
cy.get('input[data-testid=newFolderInput]').type(Cypress.env('AUDIOBOOK_LIBRARY_PATH'))
cy.get('[data-testid=metadataProvider]').click().get('[id=listbox-option-audible]').click()
cy.get('[data-testid=createLibrary]').click()
})
// Save the library ID to the settings fixture
cy.wait('@apiCall').then((interception) => {
expect(interception.response.statusCode).to.eq(200)
})
// Wait for the success notification to appear and the library name to show up in the list
cy.toastSuccess().should('be.visible')
cy.get(".list-group").contains(".item", Cypress.env('AUDIOBOOK_LIBRARY_NAME'))
})
it('Scan the library', () => {
cy.loginAsAdmin()
cy.visit('/', {
onBeforeLoad(win) {
// This allows us to wait for the the 'Scan complete received' message in the console log
cy.stub(win.console, 'log').as('consoleLog')
},
})
// Press the scan button and confirm that scanning started
cy.get('[data-testid="ButtonScanLibrary"]').click()
cy.toastSuccess().should('be.visible')
// Wait for the scan to complete
cy.get('@consoleLog', { timeout: 20000 }).should('be.calledWith', 'Scan complete received')
})
})

View file

@ -0,0 +1,73 @@
import books from '../../fixtures/bookMetadata/index'
import data from '../../fixtures/token.json'
const headers = { Authorization: 'Bearer ' + data.bearerToken }
describe('Compare the metadata in the library to the reference files', () => {
let libraryID, libraryItems
before(() => {
// Get the library ID of env.AUDIOBOOK_LIBRARY_NAME
cy.request({
url: '/api/libraries/',
headers: headers,
}).then((res) => {
expect(res.status).to.eq(200)
libraryID = res.body.libraries.find(x => x.name == Cypress.env('AUDIOBOOK_LIBRARY_NAME')).id
})
// Load all library items
cy.then(() => {
cy.request({
url: `api/libraries/${libraryID}/items`,
headers: headers,
body: { limit: 0 }
}).then((res) => {
expect(res.status).to.eq(200)
libraryItems = res.body
})
})
})
it('Check that the library contains more than zero items', () => {
expect(libraryItems.total).to.be.greaterThan(0)
expect(libraryItems.total).to.equal(libraryItems.results.length)
})
for (const expectedMetadata of books) {
it(`Verify metadata for "${expectedMetadata.relPath}"`, function () {
// Use the relPath to search for the library item
const thisItem = libraryItems.results.find(x => x.relPath == expectedMetadata.relPath)
expect(thisItem).to.have.property("id")
cy.request({
url: `/api/items/${thisItem.id}`,
headers: headers,
qs: { expanded: 0 }
}).then((res) => {
expect(res.status).to.eq(200)
expect(res.body).to.deep.include.all.keys(expectedMetadata)
expect(res.body.media.metadata.authors.map(e => ({ name: e.name }))).to.have.deep.members(expectedMetadata.media.metadata.authors)
expect(res.body.media.metadata.series.map(e => ({ name: e.name, sequence: e.sequence }))).to.have.deep.members(expectedMetadata.media.metadata.series)
expect(res.body).to.nested.deep.include({
'media.metadata.title': expectedMetadata.media.metadata.title,
'media.metadata.subtitle': expectedMetadata.media.metadata.subtitle,
'media.metadata.narrators': expectedMetadata.media.metadata.narrators,
'media.metadata.description': expectedMetadata.media.metadata.description,
'media.metadata.genres': expectedMetadata.media.metadata.genres,
// 'media.metadata.publisher': expectedMetadata.media.metadata.publisher,
'media.metadata.asin': expectedMetadata.media.metadata.asin,
'media.chapters': expectedMetadata.media.chapters,
})
})
})
}
})

View file

@ -0,0 +1,50 @@
import books from '../../fixtures/bookMetadata/index'
describe('Play an audiobook', () => {
// Use the relative path of an audiobook for the test, so it doesn't depend on the book's ID.
let testAudiobookId
const testItemRelPath = books[0].relPath
beforeEach(() => {
cy.loginAsAdmin()
cy.getLibraryItemIdFromRelPath(Cypress.env('AUDIOBOOK_LIBRARY_NAME'), testItemRelPath).then((itemID) => {
testAudiobookId = itemID
cy.visit('/item/' + testAudiobookId)
})
})
it(`Navigate to an audiobook, press play, and verify that audio is playing (${testItemRelPath})`, () => {
// Press play and check that the request was sent
cy.intercept('POST', '/api/items/' + testAudiobookId + '/play').as('play');
cy.get("[data-testid=playButton]").click()
cy.wait('@play')
// Check that the player ui opened
cy.get("#streamContainer").should("exist")
// Check that the timestamp display is changing. This rely's on cypress's retry feature
var timestamp = '0:00'
for (let i = 0; i < 3; i++) {
cy.get('[data-testid=currentTimestamp]').invoke('text').should((currentTimestamp) => {
expect(currentTimestamp).to.not.equal(timestamp)
timestamp = currentTimestamp
})
}
// Check that the audio element is actually loaded and playing
cy.get("audio").should((audioElement) => {
expect(audioElement.get(0).duration).to.be.greaterThan(0)
expect(audioElement.get(0).paused).to.be.false
})
// Press the pause button and verify that audio is paused
cy.get("[data-testid=playPause]").click()
cy.get("audio").should((audioElement) => {
expect(audioElement.get(0).paused).to.be.true
})
})
})

View file

@ -0,0 +1,11 @@
describe('Login', () => {
it('log in as an admin', () => {
cy.loginAsAdmin()
cy.visit('/')
})
it('log in as a user', () => {
cy.loginAsUser()
cy.visit('/')
})
})

View file

@ -0,0 +1,50 @@
const testUser = {
username: 'Test User',
password: 'test1234',
}
describe('Manage Users', () => {
beforeEach(() => {
cy.loginAsAdmin()
cy.visit('/config/users')
cy.get('table[id=accounts').contains('td', Cypress.env('ADMIN_USERNAME')).should('be.visible')
})
it('create a new user', () => {
// Skip if the test user already exists
cy.get('table[id=accounts').then(($el) => {
cy.skipIf($el.find(`td:contains(${testUser.username})`).length > 0)
})
cy.get('button').contains("add").click()
cy.intercept('POST', '/api/users').as('apiCall')
cy.get('form').filter(':visible').within(() => {
cy.get('[data-testid=newUsername]').find('div').find('input').type(testUser.username)
cy.get('[data-testid=newPassword]').find('div').find('input').type(testUser.password)
cy.get('button[type=submit]').click()
})
cy.wait('@apiCall').then((interception) => {
expect(interception.response.statusCode).to.eq(200)
expect(interception.response.body.user.username).to.eq(testUser.username)
})
cy.toastSuccess().should('be.visible')
})
it('delete the newly created user', () => {
// Skip if the test user doesn't exists
cy.get('table[id=accounts').then(($el) => {
cy.skipIf($el.find(`td:contains(${testUser.username})`).length === 0)
})
cy.get('table[id=accounts').contains('td', testUser.username).siblings().contains('button', 'delete').click()
cy.get('table[id=accounts').contains('td', testUser.username).should('not.exist')
cy.toastSuccess().should('be.visible')
})
})

View file

@ -0,0 +1,410 @@
{
"path": "/audiobooks/Caitlin Starling/Last to Leave the Room",
"relPath": "Caitlin Starling/Last to Leave the Room",
"isFile": false,
"isMissing": false,
"isInvalid": false,
"mediaType": "book",
"media": {
"metadata": {
"title": "Last to Leave the Room",
"subtitle": "A Novel",
"authors": [
{
"name": "Caitlin Starling"
}
],
"narrators": [
"Xe Sands"
],
"series": [],
"genres": [
"Literature & Fiction",
"Mystery, Thriller & Suspense"
],
"publishedYear": "2023",
"publishedDate": null,
"publisher": "Macmillan Audio",
"description": "Last to Leave the Room is a new novel of genre-busting speculative horror from Caitlin Starling, the acclaimed author of The Death of Jane Lawrence.  The city of San Siroco is sinking. The basement of Dr. Tamsin Rivers, the arrogant, selfish head of the research team assigned to find the source of the subsidence, is sinking faster.   As Tamsin grows obsessed with the distorting dimensions of the room at the bottom of the stairs, she finds a door that didnt exist before—and one night, it opens to reveal an exact physical copy of her. This doppelgänger is sweet and biddable where Tamsin is calculating and cruel. It appears fully, terribly human, passing every test Tamsin can devise. But the longer the double exists, the more Tamsin begins to forget pieces of her life, to lose track of time, to grow terrified of the outside world.   As her employer grows increasingly suspicious, Tamsin must try to hold herself together long enough to figure out what her double wants from her, and just where the mysterious door leads… A Macmillan Audio production from St. Martins Press.",
"isbn": null,
"asin": "B0BVKY5K9B",
"language": "English",
"explicit": false,
"abridged": false
},
"tags": [
"Horror",
"Thriller & Suspense",
"Supernatural"
],
"audioFiles": [
{
"index": 1,
"metadata": {
"filename": "Caitlin Starling - Last to Leave the Room.mp3",
"ext": ".mp3",
"path": "/audiobooks/Caitlin Starling/Last to Leave the Room/Caitlin Starling - Last to Leave the Room.mp3",
"relPath": "Caitlin Starling - Last to Leave the Room.mp3"
},
"trackNumFromMeta": null,
"discNumFromMeta": null,
"trackNumFromFilename": null,
"discNumFromFilename": null,
"manuallyVerified": false,
"invalid": false,
"exclude": false,
"error": null,
"format": "MP2/3 (MPEG audio layer 2/3)",
"duration": 42690.862756,
"bitRate": 64000,
"language": null,
"codec": "mp3",
"timeBase": "1/14112000",
"channels": 2,
"channelLayout": "stereo",
"chapters": [],
"embeddedCoverArt": "mjpeg",
"metaTags": {
"tagAlbum": "Last to Leave the Room - A Novel",
"tagArtist": "Caitlin Starling",
"tagGenre": "Thriller",
"tagTitle": "Last to Leave the Room",
"tagSubtitle": "A Novel",
"tagDate": "2023",
"tagPublisher": "Macmillan Audio",
"tagComment": "<p><b><i>Last to Leave the Room</i> is a new novel of genre-busting speculative horror from Caitlin Starling, the acclaimed author of <i>The Death of Jane Lawrence</i>.</b><br>The city of San Siroco is sinking. The basement of Dr. Tamsin Rivers, the arrogant, selfish head of the research team assigned to find the source of the subsidence, is sinking faster. <br>As Tamsin grows obsessed with the distorting dimensions of the room at the bottom of the stairs, she finds a door that didn't exist before - and one night, it opens to reveal an exact physical copy of her. This doppelgänger is sweet and biddable where Tamsin is calculating and cruel. It appears fully, terribly human, passing every test Tamsin can devise. But the longer the double exists, the more Tamsin begins to forget pieces of her life, to lose track of time, to grow terrified of the outside world. <br>As her employer grows increasingly suspicious, Tamsin must try to hold herself together long enough to figure out what...",
"tagEncodedBy": "OverDrive, Inc.",
"tagLanguage": "English",
"tagOverdriveMediaMarker": "<Markers><Marker><Name>Last to Leave the Room</Name><Time>0:00.000</Time></Marker><Marker><Name>Chapter 1</Name><Time>0:33.000</Time></Marker><Marker><Name>Chapter 2</Name><Time>17:29.000</Time></Marker><Marker><Name>Chapter 3</Name><Time>34:04.000</Time></Marker><Marker><Name>Chapter 4</Name><Time>42:18.000</Time></Marker></Markers>"
},
"mimeType": "audio/mpeg"
}
],
"chapters": [
{
"id": 0,
"start": 0,
"end": 32.035,
"title": "Opening Credits"
},
{
"id": 1,
"start": 32.035,
"end": 36.035,
"title": "Dedication"
},
{
"id": 2,
"start": 36.035,
"end": 73.035,
"title": "Epigraph"
},
{
"id": 3,
"start": 73.035,
"end": 76.035,
"title": "The City"
},
{
"id": 4,
"start": 76.035,
"end": 1048.906,
"title": "Chapter One"
},
{
"id": 5,
"start": 1048.906,
"end": 2044.856,
"title": "Chapter Two"
},
{
"id": 6,
"start": 2044.856,
"end": 2539.533,
"title": "Chapter Three"
},
{
"id": 7,
"start": 2539.533,
"end": 3684.416,
"title": "Chapter Four"
},
{
"id": 8,
"start": 3684.416,
"end": 3687.416,
"title": "The Door"
},
{
"id": 9,
"start": 3687.416,
"end": 4409.435,
"title": "Chapter Five"
},
{
"id": 10,
"start": 4409.435,
"end": 5535.37,
"title": "Chapter Six"
},
{
"id": 11,
"start": 5535.37,
"end": 6413.223,
"title": "Chapter Seven"
},
{
"id": 12,
"start": 6413.223,
"end": 7447.37,
"title": "Chapter Eight"
},
{
"id": 13,
"start": 7447.37,
"end": 8443.691,
"title": "Chapter Nine"
},
{
"id": 14,
"start": 8443.691,
"end": 9129.26,
"title": "Chapter Ten"
},
{
"id": 15,
"start": 9129.26,
"end": 9132.26,
"title": "The Double"
},
{
"id": 16,
"start": 9132.26,
"end": 10044.567,
"title": "Chapter Eleven"
},
{
"id": 17,
"start": 10044.567,
"end": 11180.998,
"title": "Chapter Twelve"
},
{
"id": 18,
"start": 11180.998,
"end": 12357.994,
"title": "Chapter Thirteen"
},
{
"id": 19,
"start": 12357.994,
"end": 12989.623,
"title": "Chapter Fourteen"
},
{
"id": 20,
"start": 12989.623,
"end": 13872.91,
"title": "Chapter Fifteen"
},
{
"id": 21,
"start": 13872.91,
"end": 15221.942,
"title": "Chapter Sixteen"
},
{
"id": 22,
"start": 15221.942,
"end": 16358.233,
"title": "Chapter Seventeen"
},
{
"id": 23,
"start": 16358.233,
"end": 17354.299,
"title": "Chapter Eighteen"
},
{
"id": 24,
"start": 17354.299,
"end": 18522.634,
"title": "Chapter Nineteen"
},
{
"id": 25,
"start": 18522.634,
"end": 19931.899,
"title": "Chapter Twenty"
},
{
"id": 26,
"start": 19931.899,
"end": 21005.938,
"title": "Chapter Twenty-One"
},
{
"id": 27,
"start": 21005.938,
"end": 21764.023,
"title": "Chapter Twenty-Two"
},
{
"id": 28,
"start": 21764.023,
"end": 22707.449,
"title": "Chapter Twenty-Three"
},
{
"id": 29,
"start": 22707.449,
"end": 23436.718,
"title": "Chapter Twenty-Four"
},
{
"id": 30,
"start": 23436.718,
"end": 24513.102,
"title": "Chapter Twenty-Five"
},
{
"id": 31,
"start": 24513.102,
"end": 25569.261,
"title": "Chapter Twenty-Six"
},
{
"id": 32,
"start": 25569.261,
"end": 26397.516,
"title": "Chapter Twenty-Seven"
},
{
"id": 33,
"start": 26397.516,
"end": 27311.43,
"title": "Chapter Twenty-Eight"
},
{
"id": 34,
"start": 27311.43,
"end": 27866.503,
"title": "Chapter Twenty-Nine"
},
{
"id": 35,
"start": 27866.503,
"end": 27870.503,
"title": "Tamsin0"
},
{
"id": 36,
"start": 27870.503,
"end": 29225.775,
"title": "Chapter Thirty"
},
{
"id": 37,
"start": 29225.775,
"end": 30688.678,
"title": "Chapter Thirty-One"
},
{
"id": 38,
"start": 30688.678,
"end": 31611.856,
"title": "Chapter Thirty-Two"
},
{
"id": 39,
"start": 31611.856,
"end": 32799.881,
"title": "Chapter Thirty-Three"
},
{
"id": 40,
"start": 32799.881,
"end": 33790.839,
"title": "Chapter Thirty-Four"
},
{
"id": 41,
"start": 33790.839,
"end": 34674.172,
"title": "Chapter Thirty-Five"
},
{
"id": 42,
"start": 34674.172,
"end": 36041.989,
"title": "Chapter Thirty-Six"
},
{
"id": 43,
"start": 36041.989,
"end": 37203.451,
"title": "Chapter Thirty-Seven"
},
{
"id": 44,
"start": 37203.451,
"end": 38207.388,
"title": "Chapter Thirty-Eight"
},
{
"id": 45,
"start": 38207.388,
"end": 39359.051,
"title": "Chapter Thirty-Nine"
},
{
"id": 46,
"start": 39359.051,
"end": 40372.323,
"title": "Chapter Forty"
},
{
"id": 47,
"start": 40372.323,
"end": 41780.102,
"title": "Chapter Forty-One"
},
{
"id": 48,
"start": 41780.102,
"end": 42619.387,
"title": "Chapter Forty-Two"
},
{
"id": 49,
"start": 42619.387,
"end": 42690.863,
"title": "End Credits"
}
],
"missingParts": [],
"ebookFile": null
},
"libraryFiles": [
{
"metadata": {
"filename": "Caitlin Starling - Last to Leave the Room.jpg",
"ext": ".jpg",
"path": "/audiobooks/Caitlin Starling/Last to Leave the Room/Caitlin Starling - Last to Leave the Room.jpg",
"relPath": "Caitlin Starling - Last to Leave the Room.jpg"
},
"isSupplementary": null,
"fileType": "image"
},
{
"metadata": {
"filename": "Caitlin Starling - Last to Leave the Room.mp3",
"ext": ".mp3",
"path": "/audiobooks/Caitlin Starling/Last to Leave the Room/Caitlin Starling - Last to Leave the Room.mp3",
"relPath": "Caitlin Starling - Last to Leave the Room.mp3"
},
"isSupplementary": null,
"fileType": "audio"
}
]
}

View file

@ -0,0 +1,433 @@
{
"path": "/audiobooks/Amal El-Mohtar/This Is How You Lose the Time War",
"relPath": "Amal El-Mohtar/This Is How You Lose the Time War",
"isFile": false,
"isMissing": false,
"isInvalid": false,
"mediaType": "book",
"media": {
"metadata": {
"title": "This Is How You Lose the Time War",
"subtitle": null,
"authors": [
{
"name": "Amal El-Mohtar"
},
{
"name": "Max Gladstone"
}
],
"narrators": [
"Cynthia Farrell",
"Emily Woo Zeller"
],
"series": [],
"genres": [
"Romance",
"Science Fiction & Fantasy"
],
"publishedYear": "2019",
"publishedDate": null,
"publisher": "Simon & Schuster Audio",
"description": "HUGO AWARD WINNER: BEST NOVELLA WINNER OF THE NEBULA AND LOCUS AWARDS: BEST NOVELLA “[An] exquisitely crafted tale...Part epistolary romance, part mind-blowing science fiction adventure, this dazzling story unfolds bit by bit, revealing layers of meaning as it plays with cause and effect, wildly imaginative technologies, and increasingly intricate wordplay...This short novel warrants multiple readings to fully unlock its complexities.” (Publishers Weekly, starred review).  From award-winning authors Amal El-Mohtar and Max Gladstone comes an enthralling, romantic novel spanning time and space about two time-traveling rivals who fall in love and must change the past to ensure their future.  Among the ashes of a dying world, an agent of the Commandment finds a letter. It reads: Burn before reading.  Thus begins an unlikely correspondence between two rival agents hellbent on securing the best possible future for their warring factions. Now, what began as a taunt, a battlefield boast, becomes something more. Something epic. Something romantic. Something that could change the past and the future.  Except the discovery of their bond would mean the death of each of them. Theres still a war going on, after all. And someone has to win. Thats how war works, right?  Cowritten by two beloved and award-winning sci-fi writers, This Is How You Lose the Time War is an epic love story spanning time and space.",
"isbn": "9781534430990",
"asin": "1508287058",
"language": "English",
"explicit": false,
"abridged": false
},
"tags": [
"Science Fiction",
"Time Travel"
],
"audioFiles": [
{
"index": 1,
"metadata": {
"filename": "Amal El-Mohtar - This Is How You Lose the Time War.m4b",
"ext": ".m4b",
"path": "/audiobooks/Amal El-Mohtar/This Is How You Lose the Time War/Amal El-Mohtar - This Is How You Lose the Time War.m4b",
"relPath": "Amal El-Mohtar - This Is How You Lose the Time War.m4b"
},
"trackNumFromMeta": null,
"discNumFromMeta": null,
"trackNumFromFilename": null,
"discNumFromFilename": null,
"manuallyVerified": false,
"invalid": false,
"exclude": false,
"error": null,
"format": "QuickTime / MOV",
"duration": 15367.268,
"bitRate": 125588,
"language": "und",
"codec": "aac",
"timeBase": "1/44100",
"channels": 2,
"channelLayout": "stereo",
"chapters": [
{
"id": 0,
"start": 0,
"end": 45.65,
"title": "001"
},
{
"id": 1,
"start": 45.65,
"end": 735.84,
"title": "002"
},
{
"id": 2,
"start": 735.84,
"end": 1112.514,
"title": "003"
},
{
"id": 3,
"start": 1112.514,
"end": 1717.928,
"title": "004"
},
{
"id": 4,
"start": 1717.928,
"end": 2238.38,
"title": "005"
},
{
"id": 5,
"start": 2238.38,
"end": 2801.557,
"title": "006"
},
{
"id": 6,
"start": 2801.557,
"end": 3471.383,
"title": "007"
},
{
"id": 7,
"start": 3471.383,
"end": 4094.258,
"title": "008"
},
{
"id": 8,
"start": 4094.258,
"end": 4957.715,
"title": "009"
},
{
"id": 9,
"start": 4957.715,
"end": 5608.71,
"title": "010"
},
{
"id": 10,
"start": 5608.71,
"end": 6413.165,
"title": "011"
},
{
"id": 11,
"start": 6413.165,
"end": 7038.316,
"title": "012"
},
{
"id": 12,
"start": 7038.316,
"end": 7444.967,
"title": "013"
},
{
"id": 13,
"start": 7444.967,
"end": 8200.196,
"title": "014"
},
{
"id": 14,
"start": 8200.196,
"end": 8573.155,
"title": "015"
},
{
"id": 15,
"start": 8573.155,
"end": 9578.138,
"title": "016"
},
{
"id": 16,
"start": 9578.138,
"end": 9968.535,
"title": "017"
},
{
"id": 17,
"start": 9968.535,
"end": 10889.972,
"title": "018"
},
{
"id": 18,
"start": 10889.972,
"end": 11612.438,
"title": "019"
},
{
"id": 19,
"start": 11612.438,
"end": 11978.361,
"title": "020"
},
{
"id": 20,
"start": 11978.361,
"end": 12672.754,
"title": "021"
},
{
"id": 21,
"start": 12672.754,
"end": 13294.933,
"title": "022"
},
{
"id": 22,
"start": 13294.933,
"end": 13857.134,
"title": "023"
},
{
"id": 23,
"start": 13857.134,
"end": 14249.296,
"title": "024"
},
{
"id": 24,
"start": 14249.296,
"end": 14799.702,
"title": "025"
},
{
"id": 25,
"start": 14799.702,
"end": 15367.268,
"title": "026"
}
],
"embeddedCoverArt": "mjpeg",
"metaTags": {
"tagAlbum": "This Is How You Lose the Time War",
"tagArtist": "Amal El-Mohtar, Max Gladstone",
"tagGenre": "Audiobook",
"tagTitle": "This Is How You Lose the Time War",
"tagDate": "2019",
"tagComposer": "Emily Woo Zeller, Cynthia Farrell",
"tagComment": "Among the ashes of a dying world, an agent of the Commandant finds a letter. It reads: Burn before reading. Thus begins an unlikely correspondence between two rival agents hellbent on securing the best possible future for their warring factions. Now, wha",
"tagDescription": "Among the ashes of a dying world, an agent of the Commandant finds a letter. It reads: Burn before reading. Thus begins an unlikely correspondence between two rival agents hellbent on securing the best possible future for their warring factions. Now, what began as a taunt, a battlefield boast, grows into something more. Something epic. Something romantic. Something that could change the past and the future. Except the discovery of their bond would mean death for each of them. There's still a war going on, after all. And someone has to win that war. That's how war works. Right?"
},
"mimeType": "audio/mp4"
}
],
"chapters": [
{
"id": 0,
"start": 0,
"end": 45.65,
"title": "001"
},
{
"id": 1,
"start": 45.65,
"end": 735.84,
"title": "002"
},
{
"id": 2,
"start": 735.84,
"end": 1112.514,
"title": "003"
},
{
"id": 3,
"start": 1112.514,
"end": 1717.928,
"title": "004"
},
{
"id": 4,
"start": 1717.928,
"end": 2238.38,
"title": "005"
},
{
"id": 5,
"start": 2238.38,
"end": 2801.557,
"title": "006"
},
{
"id": 6,
"start": 2801.557,
"end": 3471.383,
"title": "007"
},
{
"id": 7,
"start": 3471.383,
"end": 4094.258,
"title": "008"
},
{
"id": 8,
"start": 4094.258,
"end": 4957.715,
"title": "009"
},
{
"id": 9,
"start": 4957.715,
"end": 5608.71,
"title": "010"
},
{
"id": 10,
"start": 5608.71,
"end": 6413.165,
"title": "011"
},
{
"id": 11,
"start": 6413.165,
"end": 7038.316,
"title": "012"
},
{
"id": 12,
"start": 7038.316,
"end": 7444.967,
"title": "013"
},
{
"id": 13,
"start": 7444.967,
"end": 8200.196,
"title": "014"
},
{
"id": 14,
"start": 8200.196,
"end": 8573.155,
"title": "015"
},
{
"id": 15,
"start": 8573.155,
"end": 9578.138,
"title": "016"
},
{
"id": 16,
"start": 9578.138,
"end": 9968.535,
"title": "017"
},
{
"id": 17,
"start": 9968.535,
"end": 10889.972,
"title": "018"
},
{
"id": 18,
"start": 10889.972,
"end": 11612.438,
"title": "019"
},
{
"id": 19,
"start": 11612.438,
"end": 11978.361,
"title": "020"
},
{
"id": 20,
"start": 11978.361,
"end": 12672.754,
"title": "021"
},
{
"id": 21,
"start": 12672.754,
"end": 13294.933,
"title": "022"
},
{
"id": 22,
"start": 13294.933,
"end": 13857.134,
"title": "023"
},
{
"id": 23,
"start": 13857.134,
"end": 14249.296,
"title": "024"
},
{
"id": 24,
"start": 14249.296,
"end": 14799.702,
"title": "025"
},
{
"id": 25,
"start": 14799.702,
"end": 14799.702,
"title": "026"
}
],
"missingParts": [],
"ebookFile": null
},
"libraryFiles": [
{
"metadata": {
"filename": "Amal El-Mohtar - This Is How You Lose the Time War.jpg",
"ext": ".jpg",
"path": "/audiobooks/Amal El-Mohtar/This Is How You Lose the Time War/Amal El-Mohtar - This Is How You Lose the Time War.jpg",
"relPath": "Amal El-Mohtar - This Is How You Lose the Time War.jpg"
},
"isSupplementary": null,
"fileType": "image"
},
{
"metadata": {
"filename": "Amal El-Mohtar - This Is How You Lose the Time War (02).jpg",
"ext": ".jpg",
"path": "/audiobooks/Amal El-Mohtar/This Is How You Lose the Time War/Amal El-Mohtar - This Is How You Lose the Time War (02).jpg",
"relPath": "Amal El-Mohtar - This Is How You Lose the Time War (02).jpg"
},
"isSupplementary": null,
"fileType": "image"
},
{
"metadata": {
"filename": "Amal El-Mohtar - This Is How You Lose the Time War.m4b",
"ext": ".m4b",
"path": "/audiobooks/Amal El-Mohtar/This Is How You Lose the Time War/Amal El-Mohtar - This Is How You Lose the Time War.m4b",
"relPath": "Amal El-Mohtar - This Is How You Lose the Time War.m4b"
},
"isSupplementary": null,
"fileType": "audio"
}
]
}

View file

@ -0,0 +1,246 @@
{
"path": "/audiobooks/Becky Chambers/A Psalm for the Wild-Built",
"relPath": "Becky Chambers/A Psalm for the Wild-Built",
"isFile": false,
"isMissing": false,
"isInvalid": false,
"mediaType": "book",
"media": {
"metadata": {
"title": "A Psalm for the Wild-Built",
"subtitle": "Monk & Robot, Book 1",
"authors": [
{
"name": "Becky Chambers"
}
],
"narrators": [
"Em Grosland"
],
"series": [
{
"name": "Monk & Robot",
"sequence": "1"
}
],
"genres": [
"Science Fiction & Fantasy"
],
"publishedYear": "2021",
"publishedDate": null,
"publisher": "Tordotcom",
"description": "\"Narrator Emmett Grosland conjures the essence of a troubled soul in search of peace in this gentle audio.\"—AudioFile Magazine In A Psalm for the Wild-Built, Hugo Award-winner Becky Chambers's delightful new Monk and Robot series gives us hope for the future. It's been centuries since the robots of Panga gained self-awareness and laid down their tools; centuries since they wandered, en masse, into the wilderness, never to be seen again; centuries since they faded into myth and urban legend. One day, the life of a tea monk is upended by the arrival of a robot, there to honor the old promise of checking in. The robot cannot go back until the question of \"what do people need?\" is answered. But the answer to that question depends on who you ask, and how. They're going to need to ask it a lot. Becky Chambers's new series asks: in a world where people have what they want, does having more matter? A Macmillan Audio production from Tordotcom",
"isbn": null,
"asin": "1250807743",
"language": "English",
"explicit": false,
"abridged": false
},
"tags": [
"Science Fiction",
"Adventure",
"Post-Apocalyptic"
],
"audioFiles": [
{
"index": 1,
"metadata": {
"filename": "Becky Chambers - A Psalm for the Wild-Built.m4b",
"ext": ".m4b",
"path": "/audiobooks/Becky Chambers/A Psalm for the Wild-Built/Becky Chambers - A Psalm for the Wild-Built.m4b",
"relPath": "Becky Chambers - A Psalm for the Wild-Built.m4b"
},
"trackNumFromMeta": 1,
"discNumFromMeta": null,
"trackNumFromFilename": null,
"discNumFromFilename": null,
"manuallyVerified": false,
"invalid": false,
"exclude": false,
"error": null,
"format": "QuickTime / MOV",
"duration": 14909.975,
"bitRate": 125588,
"language": "und",
"codec": "aac",
"timeBase": "1/44100",
"channels": 2,
"channelLayout": "stereo",
"chapters": [
{
"id": 0,
"start": 0,
"end": 21.942,
"title": "001"
},
{
"id": 1,
"start": 21.942,
"end": 321.108,
"title": "002"
},
{
"id": 2,
"start": 321.108,
"end": 2331.84,
"title": "003"
},
{
"id": 3,
"start": 2331.84,
"end": 5155.967,
"title": "004"
},
{
"id": 4,
"start": 5155.967,
"end": 6866.535,
"title": "005"
},
{
"id": 5,
"start": 6866.535,
"end": 7853.476,
"title": "006"
},
{
"id": 6,
"start": 7853.476,
"end": 9850.253,
"title": "007"
},
{
"id": 7,
"start": 9850.253,
"end": 10816.714,
"title": "008"
},
{
"id": 8,
"start": 10816.714,
"end": 12285.445,
"title": "009"
},
{
"id": 9,
"start": 12285.445,
"end": 14872.961,
"title": "010"
}
],
"embeddedCoverArt": "mjpeg",
"metaTags": {
"tagAlbum": "A Psalm for the Wild-Built",
"tagArtist": "Becky Chambers",
"tagTitle": "A Psalm for the Wild-Built",
"tagTrack": "1",
"tagAlbumArtist": "Becky Chambers",
"tagDate": "2021-07-13",
"tagComposer": "Emmett Grosland",
"tagPublisher": "Tordotcom",
"tagComment": "It's been centuries since the robots of Panga gained self-awareness and laid down their tools",
"tagDescription": "It's been centuries since the robots of Panga gained self-awareness and laid down their tools"
},
"mimeType": "audio/mp4"
}
],
"chapters": [
{
"id": 0,
"start": 0,
"end": 20.108,
"title": "Opening Credits"
},
{
"id": 1,
"start": 20.108,
"end": 25.108,
"title": "Dedication"
},
{
"id": 2,
"start": 25.108,
"end": 319.181,
"title": "Epigraph"
},
{
"id": 3,
"start": 319.181,
"end": 2329.982,
"title": "1. A Change in Vocation"
},
{
"id": 4,
"start": 2329.982,
"end": 5154.178,
"title": "2. The Best Tea Monk in Panga"
},
{
"id": 5,
"start": 5154.178,
"end": 6864.792,
"title": "3. Splendid Speckled Mosscap"
},
{
"id": 6,
"start": 6864.792,
"end": 7851.779,
"title": "4. An Object, and an Animal"
},
{
"id": 7,
"start": 7851.779,
"end": 9848.602,
"title": "5. Remnants"
},
{
"id": 8,
"start": 9848.602,
"end": 10815.109,
"title": "6. Grass Hen with Wilted Greens and Caramelized Onion"
},
{
"id": 9,
"start": 10815.109,
"end": 12283.91,
"title": "7. The Wild"
},
{
"id": 10,
"start": 12283.91,
"end": 14871.495,
"title": "8. The Summer Bear"
},
{
"id": 11,
"start": 14871.495,
"end": 14871.495,
"title": "End Credits"
}
],
"missingParts": [],
"ebookFile": null
},
"libraryFiles": [
{
"metadata": {
"filename": "Becky Chambers - A Psalm for the Wild-Built.m4b",
"ext": ".m4b",
"path": "/audiobooks/Becky Chambers/A Psalm for the Wild-Built/Becky Chambers - A Psalm for the Wild-Built.m4b",
"relPath": "Becky Chambers - A Psalm for the Wild-Built.m4b"
},
"isSupplementary": null,
"fileType": "audio"
},
{
"metadata": {
"filename": "Becky Chambers - A Psalm for the Wild-Built.jpg",
"ext": ".jpg",
"path": "/audiobooks/Becky Chambers/A Psalm for the Wild-Built/Becky Chambers - A Psalm for the Wild-Built.jpg",
"relPath": "Becky Chambers - A Psalm for the Wild-Built.jpg"
},
"isSupplementary": null,
"fileType": "image"
}
]
}

View file

@ -0,0 +1,5 @@
const book1 = require("./book1.json")
const book2 = require("./book2.json")
const book3 = require("./book3.json")
module.exports = [book1, book2, book3]

View file

@ -0,0 +1,2 @@
{}
{}

105
cypress/support/commands.js Normal file
View file

@ -0,0 +1,105 @@
import { addStreamCommands } from '@lensesio/cypress-websocket-testing'
addStreamCommands()
Cypress.Commands.add('login', (username, password) => {
cy.session(
[username, password],
() => {
cy.visit('/login')
cy.get('[data-testid=username]').find('input').type(username)
cy.get('[data-testid=password]').find('input').type(password)
cy.intercept('/login').as('localLogin')
cy.get('button[type=submit]').click()
cy.wait('@localLogin')
cy.url().should('contain', '/')
},
{
validate() {
cy.request({
url: '/api/me',
method: 'GET',
headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
}).then((response) => {
expect(response.status).to.eq(200)
expect(response.body.username).to.eq(username)
})
},
}
)
})
Cypress.Commands.add('loginAsAdmin', () => {
cy.login(Cypress.env('ADMIN_USERNAME'), Cypress.env('ADMIN_PASSWORD'))
})
Cypress.Commands.add('loginAsUser', () => {
cy.login(Cypress.env('USER_USERNAME'), Cypress.env('USER_PASSWORD'))
})
Cypress.Commands.add('skipIf', function (expression) {
if (expression) {
this.skip()
}
})
// Success popup notification
Cypress.Commands.add('toastSuccess', () => {
cy.get('.Vue-Toastification__toast--success')
})
Cypress.Commands.add('loginWithApi', () => {
cy.request({
url: '/login',
method: 'POST',
body: {
username: Cypress.env('ADMIN_USERNAME'),
password: Cypress.env('ADMIN_PASSWORD'),
},
}).then((response) => {
expect(response.status).to.eq(200)
cy.log('Logged in with token: ' + response.body.user.token)
return cy.wrap(response.body.user.token)
})
})
Cypress.Commands.add('getLibraryIDFromName', (libraryName) => {
cy.request({
url: '/api/libraries/',
headers: { Authorization: Cypress.env('ADMIN_TOKEN') },
}).then((res) => {
expect(res.status).to.eq(200)
let libraryID = res.body.libraries.find((x) => x.name == libraryName).id
return cy.wrap(libraryID)
})
})
Cypress.Commands.add('getLibraryItems', (libraryID) => {
cy.request({
url: '/api/libraries/' + libraryID + '/items',
headers: { Authorization: Cypress.env('ADMIN_TOKEN') },
body: { limit: 0 },
}).then((res) => {
expect(res.status).to.eq(200)
let libraryItems = res.body
return cy.wrap(libraryItems)
})
})
Cypress.Commands.add('getLibraryItemIdFromRelPath', (libraryName, relPath) => {
cy.getLibraryIDFromName(libraryName).then((libraryID) => {
cy.getLibraryItems(libraryID).then((libraryItems) => {
expect(
libraryItems.results.map(({ relPath }) => {
return relPath
})
).to.include(relPath, 'No library item with that relPath exists!')
let libraryItem = libraryItems.results.find((x) => x.relPath == relPath)
return cy.wrap(libraryItem.id)
})
})
})

25
cypress/support/e2e.js Normal file
View file

@ -0,0 +1,25 @@
require('./commands')
require('@cypress/code-coverage/support')
before(() => {
if (Cypress.env('SEED_DATABASE')) {
cy.exec('npm run cypress:prepare')
}
// Set ADMIN_TOKEN from the value store in fixtures/token.json
// This is set by initializeDatabase.
cy.fixture('token.json').then((token) => {
Cypress.env('ADMIN_TOKEN', 'Bearer ' + token.bearerToken)
})
// Do a healthcheck
cy.request({
url: '/healthcheck/',
method: 'GET',
}).then((res) => {
expect(res.status).to.eq(200)
})
})

47
generateTestData.js Normal file
View file

@ -0,0 +1,47 @@
// Generate blank audiobooks for running tests
const fs = require('fs');
const path = require('path');
const Ffmpeg = require("./server/libs/fluentFfmpeg")
const toneHelpers = require("./server/utils/toneHelpers")
const booksMetadata = require("./cypress/fixtures/bookMetadata/index")
const BookMetadata = require("./server/objects/metadata/BookMetadata")
const processLibraryItem = async (libraryItem) => {
const filename = path.join('./cypress/fixtures/audiobooks', libraryItem.relPath, libraryItem.media.audioFiles[0].metadata.filename)
// Make a directory
if (fs.existsSync(path.dirname(filename)))
fs.rmSync(path.dirname(filename), { recursive: true });
fs.mkdirSync(path.dirname(filename), { recursive: true });
const toneJsonPath = path.join('./cypress/fixtures/audiobooks', libraryItem.relPath, 'tone-data.json')
const toneJsonObject = { 'ToneJsonFile': toneJsonPath, 'TrackNumber': 1 }
libraryItem.media.metadata = new BookMetadata(libraryItem.media.metadata)
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1, libraryItem.media.audioFiles[0].mimetype)
return new Promise((resolve, reject) => {
Ffmpeg()
.input('anullsrc')
.inputFormat('lavfi')
.duration(60)
.save(filename)
.on('end', function () {
console.log('Generated testing file for processing');
toneHelpers.tagAudioFile(filename, toneJsonObject).then(function () {
fs.rmSync(toneJsonPath)
resolve();
})
})
});
}
const processItems = async () => {
for (let libraryItem of booksMetadata) {
await processLibraryItem(libraryItem);
}
}
processItems();

11550
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nodemon --watch server index.js", "dev": "nodemon --watch server index.js",
"dev:coverage": "nyc --reporter=lcov --reporter=text-summary npm run dev",
"start": "node index.js", "start": "node index.js",
"client": "cd client && npm ci && npm run generate", "client": "cd client && npm ci && npm run generate",
"prod": "npm run client && npm ci && node prod.js", "prod": "npm run client && npm ci && node prod.js",
@ -14,7 +15,11 @@
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local", "docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
"deploy-linux": "node deploy/linux" "deploy-linux": "node deploy/linux",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:prepare": "mv ./config/absdatabase.sqlite ./config/absdatabase.sqlite.bak ; (cd client; npm run generate) ; node ./generateTestData.js",
"test": "npm run cypress:prepare ; start-server-and-test dev:coverage http://localhost:3333 cypress:run"
}, },
"bin": "prod.js", "bin": "prod.js",
"pkg": { "pkg": {
@ -43,6 +48,27 @@
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.20" "@cypress/code-coverage": "^3.12.4",
"@lensesio/cypress-websocket-testing": "^1.0.1",
"cypress": "^13.3.1",
"istanbul-lib-coverage": "^3.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"start-server-and-test": "^2.0.1"
},
"nyc": {
"extension": [
".js",
".vue"
],
"exclude": [
"server/libs/**"
],
"include": [
"server/**",
"client/**"
],
"sourceMap": true,
"instrument": true
} }
} }

View file

@ -124,6 +124,9 @@ class Server {
await this.init() await this.init()
const app = express() const app = express()
if (global.__coverage__) {
require('@cypress/code-coverage/middleware/express')(app)
}
const router = express.Router() const router = express.Router()
app.use(global.RouterBasePath, router) app.use(global.RouterBasePath, router)
app.disable('x-powered-by') app.disable('x-powered-by')