Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ exclude_paths:
- ".github/**"
- "**.md"
- "Examples/**"
- "Docs/**"
- "Tests/Helpers/**"
36 changes: 36 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,39 @@ jobs:
state: released
skip-label: |
state: released

build-pages:
name: "Build Pages"
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
10.0.x
- name: Get documentation from extensions
run: ./build.sh Pages
env:
GithubToken: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: ./Docs/pages/package-lock.json
- name: Install dependencies
working-directory: ./Docs/pages
run: npm ci --ignore-scripts
- name: Build website
working-directory: ./Docs/pages
run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./Docs/pages/build
42 changes: 42 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Pages

on:
repository_dispatch:
types: [ extension-documentation-updated-event ]

jobs:
build-pages:
name: Build Pages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
10.0.x
- name: Get documentation from extensions
run: ./build.sh Pages
env:
GithubToken: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: ./Docs/pages/package-lock.json
- name: Install dependencies
working-directory: ./Docs/pages
run: npm ci --ignore-scripts
- name: Build website
working-directory: ./Docs/pages
run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./Docs/pages/build
4 changes: 3 additions & 1 deletion .idea/.idea.Testably.Abstractions/.idea/indexLayout.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"MutationTestsLinux",
"MutationTestsWindows",
"Pack",
"Pages",
"Restore",
"UnitTests"
]
Expand Down
20 changes: 20 additions & 0 deletions Docs/pages/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Dependencies
/node_modules

# Production
/build

# Generated files
.docusaurus
.cache-loader

# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
57 changes: 57 additions & 0 deletions Docs/pages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Testably.Abstractions documentation site

The documentation site for [Testably.Abstractions](https://github.com/Testably/Testably.Abstractions), built with [Docusaurus](https://docusaurus.io/) and published to <https://docs.testably.org>.

## Prerequisites

- Node.js 20 or newer

## Local development

```powershell
npm install
npm run start
```

This starts a dev server on <http://localhost:3000> with live reload.

## Production build

```powershell
npm run build
npm run serve # serves the static build locally for verification
```

The built site is written to `Docs/pages/build`.

## Deployment

Two workflows publish the docs:

- **`.github/workflows/build.yml`** (`name: Build`) - the main CI workflow runs on every push to `main` and on release tags. Its `build-pages` job (`needs: [ pack ]`) builds and deploys the docs after tests, static analysis and packaging have all succeeded. This is the normal channel for in-repo doc changes.
- **`.github/workflows/pages.yml`** (`name: Pages`) - triggered by `repository_dispatch` events of type `extension-documentation-updated-event`. Extension repos use this to ask the site to rebuild after their own docs change, without going through the full CI build.

Both workflows run the same deploy steps: setup .NET → `./build.sh Pages` (downloads the `Docs/pages/` folder of every extension repo listed in `Pipeline/Build.Pages.cs` into `Docs/pages/docs/extensions/project/<Name>/`) → npm install → docusaurus build → publish to the `gh-pages` branch via [`peaceiris/actions-gh-pages`](https://github.com/peaceiris/actions-gh-pages).

In repository settings, GitHub Pages source must be set to **Deploy from a branch** → `gh-pages` / `(root)`.

The custom domain `docs.testably.org` is configured via the `static/CNAME` file. DNS for that subdomain must point to `testably.github.io` (CNAME record) for the custom domain to resolve.

### Adding an extension project

1. Add an entry to the `projects` dictionary in [`Pipeline/Build.Pages.cs`](../../Pipeline/Build.Pages.cs):
```csharp
{ "Testably.Abstractions.MyExtension", "MyExtension" }
```
The key is the GitHub repository name under the `Testably` org; the value is the directory under `Docs/pages/docs/extensions/project/`.
2. In the extension repo, place documentation pages under `Docs/pages/` and an optional `README.md` (the section from the first `##` header onwards is substituted into any local file beginning with `00-` that contains the `{README}` placeholder).
3. From the extension repo's CI, dispatch the `extension-documentation-updated-event` event to this repo so it rebuilds.

## Editing content

- Documentation pages live under `docs/`.
- Top-level navigation order is controlled by `sidebar_position` frontmatter on individual pages and by `_category_.json` in folder roots.
- The landing page is `src/pages/index.tsx`; the feature cards underneath are in `src/components/HomepageFeatures/`.
- Site-wide config (title, navbar, footer) lives in `docusaurus.config.ts`.

When editing example pages under `docs/examples/`, keep them in sync with the source READMEs at the top of the corresponding `Examples/<name>/` folder of the repository.
4 changes: 4 additions & 0 deletions Docs/pages/docs/companion-libraries/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "Companion libraries",
"position": 6
}
46 changes: 46 additions & 0 deletions Docs/pages/docs/companion-libraries/access-control.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
sidebar_position: 2
title: Access control
---

# Testably.Abstractions.AccessControl

[![NuGet](https://img.shields.io/nuget/v/Testably.Abstractions.AccessControl?label=NuGet&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions.AccessControl)

Implements the methods from [`FileSystemAclExtensions`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemaclextensions) on the `IFileSystem` interface so production code that reads or writes ACLs works against both real and mocked file systems.

```powershell
dotnet add package Testably.Abstractions.AccessControl
```

## What it adds

| Extension on | Methods |
|-------------------------|----------------------------------------------------------------------------------------|
| `IDirectory` | `CreateDirectory(path, DirectorySecurity)`, `GetAccessControl`, `SetAccessControl` |
| `IDirectoryInfo` | `Create(DirectorySecurity)`, `GetAccessControl`, `SetAccessControl` |
| `IFile` | `GetAccessControl`, `SetAccessControl` |
| `IFileInfo` | `GetAccessControl`, `SetAccessControl` |
| `FileSystemStream` | `GetAccessControl`, `SetAccessControl` |

Each `GetAccessControl` overload accepts an optional `AccessControlSections` mask, matching the BCL.

```csharp
IFileSystem fileSystem; // injected

DirectorySecurity acl = fileSystem.Directory.GetAccessControl("data");
fileSystem.Directory.SetAccessControl("backup", acl);

fileSystem.File.WriteAllText("secret.txt", "x");
FileSecurity fileAcl = fileSystem.File.GetAccessControl("secret.txt");
```

## Behaviour on the mock

On `RealFileSystem` the calls forward to `System.IO.FileSystemAclExtensions`. On `MockFileSystem` the ACL object is stored as metadata via [`IFileSystemExtensibility`](../file-system/extensibility) and round-tripped - set it, read it back, and it will be the same object.

To simulate *enforcement* (denying a read or write based on the ACL), combine the package with [`MockFileSystem.WithAccessControlStrategy(...)`](../file-system/access-control).

:::warning[Windows-only]
All ACL APIs are marked `[SupportedOSPlatform("windows")]`. Calling them on Linux/macOS throws `PlatformNotSupportedException`.
:::
106 changes: 106 additions & 0 deletions Docs/pages/docs/companion-libraries/compression.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
sidebar_position: 1
title: Compression
---

# Testably.Abstractions.Compression

[![NuGet](https://img.shields.io/nuget/v/Testably.Abstractions.Compression?label=NuGet&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions.Compression)

Wraps `System.IO.Compression.ZipFile` and `System.IO.Compression.ZipArchive` as extensions on `IFileSystem`.

```powershell
dotnet add package Testably.Abstractions.Compression
```

## Why this package exists

`System.IO.Compression.ZipFile` operates on real `string` paths and `System.IO.Compression.ZipArchive` operates on `Stream`s - neither knows about `IFileSystem`. Without the companion package, bridging the two means writing your own glue:

```csharp
// With Testably.Abstractions.Compression - one line:
fileSystem.ZipFile().CreateFromDirectory("source", "out.zip");
```

<details>
<summary>Without the companion package (~15 lines of glue)</summary>

```csharp
using MemoryStream buffer = new();
using (ZipArchive archive = new(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
AddDirectory("", "source");

void AddDirectory(string prefix, string directory)
{
foreach (string file in fileSystem.Directory.GetFiles(directory))
{
ZipArchiveEntry entry = archive.CreateEntry(prefix + Path.GetFileName(file));
using Stream entryStream = entry.Open();
entryStream.Write(fileSystem.File.ReadAllBytes(file));
}
foreach (string sub in fileSystem.Directory.GetDirectories(directory))
{
string name = fileSystem.Path.GetFileName(sub);
archive.CreateEntry(prefix + name + "/");
AddDirectory(prefix + name + "/", sub);
}
}
}
buffer.Position = 0;
using FileSystemStream output = fileSystem.File.Create("out.zip");
buffer.CopyTo(output);
```

Extraction looks similar: open a `ZipArchive` in `Read` mode, loop the entries, recreate directories on the file system, copy each entry's stream into `fileSystem.File.WriteAllBytes`.

</details>

The package replaces this glue with a one-line call. Beyond brevity, it also gives you stream/path overloads on both sides, async variants on .NET 9+, and an `IZipArchive` / `IZipArchiveEntry` wrapper so the abstraction extends through the archive itself rather than stopping at the zip boundary.

### Production uses the real BCL implementation

The wrapper isn't just a uniform façade - at runtime it forwards every call to the matching `System.IO.Compression.ZipFile` / `ZipArchive` method when `IFileSystem` is a `RealFileSystem`, and to an in-memory equivalent only when it's a `MockFileSystem`:

```csharp
// Inside ZipFileWrapper.CreateFromDirectory:
Execute.WhenRealFileSystem(FileSystem,
() => ZipFile.CreateFromDirectory(sourceDirectoryName, destination), // real → BCL
() => ZipUtilities.CreateFromDirectory(FileSystem, sourceDirectoryName, destination)); // mock → in-memory
```

That means production code keeps every native optimisation the BCL ships - kernel-level streaming, no extra `MemoryStream` allocation, the latest deflate tuning - while tests run against a deterministic in-memory implementation. You don't trade performance for testability.

## ZipFile

`fileSystem.ZipFile()` exposes the static methods of `System.IO.Compression.ZipFile`:

```csharp
fileSystem.ZipFile()
.CreateFromDirectory("your-directory", "your-file.zip");

fileSystem.ZipFile()
.ExtractToDirectory("your-file.zip", "your-destination");

using IZipArchive archive = fileSystem.ZipFile()
.Open("your-file.zip", ZipArchiveMode.Update);
```

All overloads from the BCL are present, including the async variants on .NET 9+ (`CreateFromDirectoryAsync`, `ExtractToDirectoryAsync`, `OpenAsync`, `OpenReadAsync`).

## ZipArchive

`fileSystem.ZipArchive()` is a factory for `IZipArchive` from a `Stream` (matches the `new ZipArchive(...)` constructors):

```csharp
using FileSystemStream stream = fileSystem.File.OpenRead("your-file.zip");
using IZipArchive archive = fileSystem.ZipArchive()
.New(stream, ZipArchiveMode.Read);

foreach (IZipArchiveEntry entry in archive.Entries)
{
Console.WriteLine(entry.FullName);
}
```

`IZipArchive` exposes `Entries`, `Mode`, `CreateEntry`, `CreateEntryFromFile`, `GetEntry`, `ExtractToDirectory` and their async counterparts. `IZipArchiveEntry` mirrors `ZipArchiveEntry` (`Open`, `Delete`, `LastWriteTime`, `Length`, `CompressedLength`, …).
14 changes: 14 additions & 0 deletions Docs/pages/docs/companion-libraries/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: Companion libraries
---

# Companion libraries

`Testably.Abstractions` ships two optional NuGet packages that extend `IFileSystem` with capabilities the BCL keeps in separate namespaces.

| Package | Adds to `IFileSystem` |
|------------------------------------------|--------------------------------------------------------------------|
| `Testably.Abstractions.Compression` | `ZipFile()` and `ZipArchive()` extension methods |
| `Testably.Abstractions.AccessControl` | `GetAccessControl` / `SetAccessControl` on files and directories |

Both packages target the `IFileSystem` interface, so they work transparently against either `RealFileSystem` or `MockFileSystem`.
4 changes: 4 additions & 0 deletions Docs/pages/docs/file-system/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "File system",
"position": 3
}
27 changes: 27 additions & 0 deletions Docs/pages/docs/file-system/access-control.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
sidebar_position: 7
title: Access control (ACL)
---

# Access control (ACL)

To simulate Windows ACL-based access restrictions, `MockFileSystem` accepts an `IAccessControlStrategy` via `WithAccessControlStrategy(...)`.

The default implementation takes a callback `(path, extensibility) => bool` - return `false` to deny access to that path. When access is denied, the next read/write operation throws `IOException` with the message `"Access to the path '{fullPath}' is denied."`, matching what the real Windows file system does.

```csharp
MockFileSystem fileSystem = new();
fileSystem.WithAccessControlStrategy(
new DefaultAccessControlStrategy((path, _) => !path.EndsWith("secret.txt")));

fileSystem.File.WriteAllText("secret.txt", "x"); // throws IOException
fileSystem.File.WriteAllText("public.txt", "x"); // ok
```

:::warning[Windows-only]
`WithAccessControlStrategy` throws `PlatformNotSupportedException` on non-Windows hosts (and when simulating a non-Windows OS). ACL functionality is intentionally tied to Windows behaviour.
:::

## Reading and writing ACL objects

For full `FileSecurity` / `DirectorySecurity` support (the actual `GetAccessControl` / `SetAccessControl` APIs), install the [`Testably.Abstractions.AccessControl`](../companion-libraries/access-control) companion package. It adds extension methods on `IFile`, `IFileInfo`, `IDirectory`, `IDirectoryInfo` and `FileSystemStream` that store ACL objects on the mock and round-trip them on the real file system.
Loading
Loading