diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index 7199f22..828f2d7 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -tests: 37 passedtests37 passed +tests: 83 passedtests83 passed diff --git a/code/+matbox/+tasks/+internal/getNamespaceName.m b/code/+matbox/+tasks/+internal/getNamespaceName.m new file mode 100644 index 0000000..2fc23f5 --- /dev/null +++ b/code/+matbox/+tasks/+internal/getNamespaceName.m @@ -0,0 +1,42 @@ +function namespaceName = getNamespaceName(folderPath) +%getNamespaceName - Get the name of the namespace that a folder defines +% namespaceName = matbox.tasks.internal.getNamespaceName(folderPath) +% returns the namespace name given by the namespace folders at the end +% of folderPath, joined with ".". Both "/" and "\" are accepted as +% separators. The result is "" if the last folder is not a namespace +% folder. +% +% Example: Namespace of a nested namespace folder +% folderPath = "src/mytoolbox/+mytoolbox/+external/+webprogress"; +% name = matbox.tasks.internal.getNamespaceName(folderPath) +% % name = "mytoolbox.external.webprogress" +% +% See also matbox.tasks.vendorPackage + + arguments + folderPath (1,1) string + end + + NAMESPACE_PREFIX = "+"; + + folderNames = split(replace(folderPath, "\", "/"), "/"); + folderNames(folderNames == "") = []; + + % Only the unbroken run of namespace folders at the end of the path + % counts, because a namespace folder below an ordinary folder starts a + % new namespace. + isNamespaceFolder = startsWith(folderNames, NAMESPACE_PREFIX); + lastOrdinaryFolder = find(~isNamespaceFolder, 1, "last"); + if isempty(lastOrdinaryFolder) + lastOrdinaryFolder = 0; + end + namespaceFolders = folderNames(lastOrdinaryFolder+1:end); + + % join returns for an empty array, so that case is set apart. + isInNamespace = ~isempty(namespaceFolders); + if isInNamespace + namespaceName = join(extractAfter(namespaceFolders, NAMESPACE_PREFIX), "."); + else + namespaceName = ""; + end +end diff --git a/code/+matbox/+tasks/+internal/renameNamespaceInText.m b/code/+matbox/+tasks/+internal/renameNamespaceInText.m new file mode 100644 index 0000000..407bccd --- /dev/null +++ b/code/+matbox/+tasks/+internal/renameNamespaceInText.m @@ -0,0 +1,47 @@ +function text = renameNamespaceInText(text, sourceNamespace, targetNamespace) +%renameNamespaceInText - Rename the references to a namespace in code +% text = matbox.tasks.internal.renameNamespaceInText(text,sourceNamespace,targetNamespace) +% replaces each reference to sourceNamespace in text, such as +% "webprogress.upload", with the same reference to targetNamespace, such +% as "mytoolbox.external.webprogress.upload". Code, strings and comments +% are all renamed. The class of text is kept. +% +% A reference is the namespace name followed by "." and a letter or "*" +% (as in "import webprogress.*"). The name is not a reference when a +% letter, digit, "_", ".", "/" or "\" comes before it, which leaves +% longer names ("mywebprogress.x"), property access ("obj.webprogress.x") +% and paths ("src/webprogress.zip") unchanged. Error identifiers +% ("webprogress:upload:Failed") have no "." after the name and are also +% unchanged. A variable with the same name as the namespace is +% indistinguishable from the namespace and is renamed. +% +% See also matbox.tasks.vendorPackage, regexprep + + arguments + text {mustBeTextScalar} + sourceNamespace (1,1) string {mustBeNamespaceName} + targetNamespace (1,1) string {mustBeNamespaceName} + end + + % The character classes are spelled out because \w may also match + % non-ASCII letters, and the text can be the raw bytes of a file. + notInsideLongerName = "(? 0 && ~isfolder(parentFolder) + mkdir(parentFolder) + end + + % The new folder is first moved next to the target. That move can copy + % between volumes and fail partway, but it does not touch the target. + % The moves after it stay within one folder, where a move is a rename. + [~, uniqueName] = fileparts(tempname()); + incomingFolder = fullfile(parentFolder, uniqueName + "_incoming"); + movefile(newFolder, incomingFolder) + incomingFolderCleanup = onCleanup(@() removeFolderIfPresent(incomingFolder)); + + if isfolder(targetFolder) + previousFolder = fullfile(parentFolder, uniqueName + "_previous"); + movefile(targetFolder, previousFolder) + try + movefile(incomingFolder, targetFolder) + catch ME + movefile(previousFolder, targetFolder) + rethrow(ME) + end + removePreviousCopy(previousFolder) + else + movefile(incomingFolder, targetFolder) + end +end + +function removePreviousCopy(previousFolder) +%removePreviousCopy - Delete the replaced copy, or warn if that fails + % The new copy is in place at this point, so a failure to delete the + % old one does not fail the call. + try + rmdir(previousFolder, "s") + catch ME + warning("MatBox:VendorPackage:PreviousCopyNotRemoved", ... + "The previous copy in ""%s"" could not be removed: %s " + ... + "Delete that folder.", previousFolder, ME.message) + end +end + +function removeFolderIfPresent(folderPath) +%removeFolderIfPresent - Remove a folder and its content if the folder exists + if isfolder(folderPath) + rmdir(folderPath, "s") + end +end diff --git a/code/+matbox/+tasks/vendorPackage.m b/code/+matbox/+tasks/vendorPackage.m new file mode 100644 index 0000000..38d161f --- /dev/null +++ b/code/+matbox/+tasks/vendorPackage.m @@ -0,0 +1,116 @@ +function vendorInfo = vendorPackage(projectRootDirectory, sourceUri, sourceFolder, targetFolder, options) +%vendorPackage - Copy a folder from a GitHub repository into a project +% matbox.tasks.vendorPackage(projectRootDirectory,sourceUri,sourceFolder,targetFolder) +% downloads the GitHub repository sourceUri and copies its folder +% sourceFolder to the folder targetFolder of the project, which is +% replaced if it exists. sourceUri has the form +% "https://github.com//[@ref]", where ref is a branch name, +% tag name or commit SHA, and the default branch if left out. +% sourceFolder is relative to the root of the repository and +% targetFolder is relative to projectRootDirectory. +% +% If the two folders are namespace folders with different namespace +% names, the references to the source namespace in the copied .m files +% are renamed. For example, copying "src/webprogress/+webprogress" to +% "src/mytoolbox/+mytoolbox/+external/+webprogress" turns +% "webprogress.upload" into "mytoolbox.external.webprogress.upload" in +% code, strings and comments. Error identifiers keep the source name. +% +% The license file of the repository is copied into targetFolder, and +% the file vendorinfo.json there records the repository, the reference, +% the commit that was copied, the two namespace names and the excluded +% files. Changes made to the copied files are lost on the next call, +% because the folder is replaced as a whole. A folder without a +% vendorinfo.json file is never replaced, and neither is a folder whose +% vendorinfo.json file records another repository or source folder. +% +% vendorInfo = matbox.tasks.vendorPackage(...) returns the information +% written to vendorinfo.json as a struct. +% +% [...] = matbox.tasks.vendorPackage(...,ExcludeFiles=FILES) leaves out +% the files FILES, given relative to sourceFolder with "/" as separator, +% for example ["toolboxdir.m","private/helper.m"]. A name that matches +% no file is an error. +% +% [...] = matbox.tasks.vendorPackage(...,LicenseFile=FILE) copies FILE, +% given relative to the root of the repository, as the license file. The +% default is "LICENSE". +% +% [...] = matbox.tasks.vendorPackage(...,Verbose=TF) prints a summary of +% the copy if TF is true, the default. +% +% Example: Copy a package into the namespace of a toolbox +% sourceUri = "https://github.com/ehennestad/http-progressbar-matlab@main"; +% sourceFolder = "src/webprogress/+webprogress"; +% targetFolder = "src/mytoolbox/external/+mytoolbox/+external/+webprogress"; +% matbox.tasks.vendorPackage(pwd, sourceUri, sourceFolder, ... +% targetFolder, ExcludeFiles=["toolboxdir.m","toolboxversion.m"]) +% +% See also matbox.installRequirements, matbox.tasks.packageToolbox + + arguments + projectRootDirectory (1,1) string {mustBeFolder} + sourceUri (1,1) string + sourceFolder (1,1) string + targetFolder (1,1) string {mustBeNonzeroLengthText, mustStayBelowRoot} + options.ExcludeFiles (1,:) string = string.empty + options.LicenseFile (1,1) string = "LICENSE" + options.Verbose (1,1) logical = true + end + + [ownerName, repositoryName, gitRef] = ... + matbox.setup.internal.github.parseRepositoryURL(sourceUri); + repositoryUrl = "https://github.com/" + ownerName + "/" + repositoryName; + + if ismissing(gitRef) + gitRef = matbox.setup.internal.github.api.getDefaultBranch( ... + ownerName, repositoryName); + end + + % The reference is resolved to a commit first and the archive of that + % commit is downloaded, so that the recorded commit is the one the files + % come from even if a branch moves between the two requests. + commitId = string(matbox.setup.internal.github.api.getCurrentCommitID( ... + repositoryName, Owner=ownerName, BranchName=gitRef)); + + downloadFolder = string(tempname()); + mkdir(downloadFolder) + downloadFolderCleanup = onCleanup(@() rmdir(downloadFolder, "s")); + + % The URL is a character vector because downloadZippedGithubRepo builds + % a file name from it by concatenation. + archiveUrl = sprintf('%s/archive/%s.zip', repositoryUrl, commitId); + isUpdate = false; + throwErrorIfFails = true; + repositoryFolder = matbox.setup.internal.downloadZippedGithubRepo( ... + archiveUrl, downloadFolder, isUpdate, throwErrorIfFails); + + vendorInfo = matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, sourceFolder, fullfile(projectRootDirectory, targetFolder), ... + ExcludeFiles=options.ExcludeFiles, ... + LicenseFile=options.LicenseFile, ... + RepositoryUrl=repositoryUrl, ... + Reference=gitRef, ... + CommitID=commitId); + + if options.Verbose + SHORT_COMMIT_LENGTH = 7; + fprintf("Copied ""%s"" of %s at commit %s to ""%s"".\n", ... + sourceFolder, repositoryUrl, ... + extractBefore(commitId, SHORT_COMMIT_LENGTH+1), targetFolder) + end + + if nargout < 1 + clear vendorInfo + end +end + +function mustStayBelowRoot(folderPath) +%mustStayBelowRoot - Validate that a relative path has no ".." folder + folderNames = split(replace(folderPath, "\", "/"), "/"); + if any(folderNames == "..") + error("MatBox:VendorPackage:InvalidTargetFolder", ... + "The target folder ""%s"" must be below the project root " + ... + "directory. Give a path without "".."".", folderPath) + end +end diff --git a/tools/tests/+matboxtools/+unittest/VendorPackageTest.m b/tools/tests/+matboxtools/+unittest/VendorPackageTest.m new file mode 100644 index 0000000..607825f --- /dev/null +++ b/tools/tests/+matboxtools/+unittest/VendorPackageTest.m @@ -0,0 +1,493 @@ +classdef VendorPackageTest < matlab.unittest.TestCase +% VendorPackageTest - Tests for copying a package of another repository into a project +% +% matbox.tasks.vendorPackage downloads a repository and hands the copy +% to matbox.tasks.internal.vendorPackageFromFolder. Most tests call the +% latter with a repository that the test creates on disk, and need no +% network access. testVendorFromGitHub downloads a tagged release of +% MatBox itself. + + properties (Constant) + SourceFolder = "src/webprogress/+webprogress" + SourceNamespace = "webprogress" + TargetNamespace = "mytoolbox.external.webprogress" + ExcludedFile = "toolboxdir.m" + LicenseText = "License of the source repository" + % A tagged release of MatBox and the commit the tag points to + GitHubSourceUri = "https://github.com/ehennestad/matbox@v0.11.0" + GitHubCommitId = "a805e35ea3905d914b41e4001e53a943519eb4d7" + end + + properties (TestParameter) + % Text with a reference to SourceNamespace, followed by the text + % expected after renaming the namespace to TargetNamespace + referenceCase = struct( ... + "functionCall", ["x = webprogress.upload(a);", ... + "x = mytoolbox.external.webprogress.upload(a);"], ... + "functionHandle", ["f = @(o) webprogress.Monitor(o{:});", ... + "f = @(o) mytoolbox.external.webprogress.Monitor(o{:});"], ... + "staticMethod", ["tf = ~webprogress.Monitor.isWebFigure(fig);", ... + "tf = ~mytoolbox.external.webprogress.Monitor.isWebFigure(fig);"], ... + "importStatement", ["import webprogress.*", ... + "import mytoolbox.external.webprogress.*"], ... + "helpText", ["% webprogress.upload(FILENAME,URL) uploads", ... + "% mytoolbox.external.webprogress.upload(FILENAME,URL) uploads"], ... + "metaClassQuery", ["?webprogress.Monitor", ... + "?mytoolbox.external.webprogress.Monitor"]) + + % Text in which the name "webprogress" is not a namespace reference + unchangedText = struct( ... + "errorIdentifier", "error(""webprogress:upload:Failed"", ""Failed."")", ... + "longerNamespaceName", "webprogresstools.projectdir()", ... + "nameWithPrefix", "mywebprogress.upload(a)", ... + "propertyAccess", "obj.webprogress.upload", ... + "folderPath", "% see src/webprogress/+webprogress", ... + "fileName", "% see https://github.com/owner/webprogress.git", ... + "endOfSentence", "% is in the namespace webprogress. It has", ... + "alreadyRenamed", "mytoolbox.external.webprogress.upload(a)") + + % A folder path followed by the name of the namespace it defines + namespaceCase = struct( ... + "singleFolder", ["src/webprogress/+webprogress", "webprogress"], ... + "nestedFolders", ["src/tb/+tb/+external/+webprogress", "tb.external.webprogress"], ... + "backslashSeparators", ["src\tb\+tb\+external", "tb.external"], ... + "trailingSeparator", ["src/+tb/", "tb"], ... + "onlyNamespaceFolders", ["+tb/+external", "tb.external"], ... + "restartedBelowOrdinaryFolder", ["+tb/external/+tb/+external", "tb.external"], ... + "ordinaryFolder", ["src/webprogress", ""], ... + "ordinaryFolderInNamespace", ["src/+tb/private", ""]) + end + + methods (TestMethodSetup) + function setupMethod(testCase) + testCase.applyFixture(matlab.unittest.fixtures.WorkingFolderFixture) + end + end + + methods (Test) % renameNamespaceInText + function testReferenceIsRenamed(testCase, referenceCase) + renamedText = matbox.tasks.internal.renameNamespaceInText( ... + referenceCase(1), testCase.SourceNamespace, testCase.TargetNamespace); + + testCase.verifyEqual(renamedText, referenceCase(2)) + end + + function testOtherTextIsUnchanged(testCase, unchangedText) + renamedText = matbox.tasks.internal.renameNamespaceInText( ... + unchangedText, testCase.SourceNamespace, testCase.TargetNamespace); + + testCase.verifyEqual(renamedText, unchangedText) + end + + function testDotsInNamespaceNameMatchLiterally(testCase) + % An unescaped "." matches any character, so "axb.run" would be + % taken for a reference to the namespace "a.b". + renamedText = matbox.tasks.internal.renameNamespaceInText( ... + "axb.run() + a.b.run()", "a.b", "c.d"); + + testCase.verifyEqual(renamedText, "axb.run() + c.d.run()") + end + + function testClassOfTextIsKept(testCase) + renamedText = matbox.tasks.internal.renameNamespaceInText( ... + 'webprogress.upload(a)', testCase.SourceNamespace, testCase.TargetNamespace); + + testCase.verifyEqual(renamedText, 'mytoolbox.external.webprogress.upload(a)') + end + + function testInvalidNamespaceNameErrors(testCase) + testCase.verifyError( ... + @() matbox.tasks.internal.renameNamespaceInText("x", "web progress", "a"), ... + "MatBox:VendorPackage:InvalidNamespaceName") + end + end + + methods (Test) % getNamespaceName + function testNamespaceNameOfFolder(testCase, namespaceCase) + namespaceName = matbox.tasks.internal.getNamespaceName(namespaceCase(1)); + + testCase.verifyEqual(namespaceName, namespaceCase(2)) + end + end + + methods (Test) % vendorPackageFromFolder + function testFilesAreCopiedAndRenamed(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + uploadCode = fileread(fullfile(targetFolder, "upload.m")); + testCase.verifySubstring(uploadCode, ... + "@(o) mytoolbox.external.webprogress.Monitor(o{:})") + testCase.verifySubstring(uploadCode, ... + "% mytoolbox.external.webprogress.upload(FILENAME,URL) uploads") + testCase.verifySubstring(uploadCode, """webprogress:upload:Failed""") + testCase.verifyTrue(isfile(fullfile(targetFolder, "private", "mustBeValidUrl.m"))) + end + + function testExcludedFilesAreNotCopied(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + ExcludeFiles=[testCase.ExcludedFile, "private/mustBeValidUrl.m"]) + + testCase.verifyFalse(isfile(fullfile(targetFolder, testCase.ExcludedFile))) + testCase.verifyFalse(isfile(fullfile(targetFolder, "private", "mustBeValidUrl.m"))) + testCase.verifyTrue(isfile(fullfile(targetFolder, "upload.m"))) + end + + function testExcludedFileMissingFromSourceErrors(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + ExcludeFiles="renamedUpstream.m"), ... + "MatBox:VendorPackage:ExcludedFileNotFound") + testCase.verifyFalse(isfolder(targetFolder)) + end + + function testLicenseFileIsCopied(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + testCase.verifyEqual( ... + string(fileread(fullfile(targetFolder, "LICENSE"))), testCase.LicenseText) + end + + function testLicenseFileInSubfolderIsCopiedByName(testCase) + repositoryFolder = testCase.createRepository(); + writeText(fullfile(repositoryFolder, "licenses", "MIT.txt"), "MIT license text") + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + LicenseFile="licenses/MIT.txt") + + testCase.verifyEqual( ... + string(fileread(fullfile(targetFolder, "MIT.txt"))), "MIT license text") + end + + function testVendorInfoIsWritten(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + returnedInfo = matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + ExcludeFiles=testCase.ExcludedFile, ... + RepositoryUrl="https://github.com/owner/webprogress", ... + Reference="main", ... + CommitID="0123abc"); + + expectedInfo = struct( ... + "Source", 'https://github.com/owner/webprogress', ... + "Reference", 'main', ... + "CommitID", '0123abc', ... + "SourceFolder", char(testCase.SourceFolder), ... + "SourceNamespace", char(testCase.SourceNamespace), ... + "TargetNamespace", char(testCase.TargetNamespace), ... + "ExcludedFiles", {{char(testCase.ExcludedFile)}}); + savedInfo = jsondecode(fileread(fullfile(targetFolder, "vendorinfo.json"))); + testCase.verifyEqual(savedInfo, expectedInfo) + testCase.verifyEqual(returnedInfo.CommitID, "0123abc") + testCase.verifyEqual(returnedInfo.ExcludedFiles, testCase.ExcludedFile) + end + + function testSecondCallGivesIdenticalFolder(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + [firstPaths, firstContents] = readFolder(targetFolder); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + [secondPaths, secondContents] = readFolder(targetFolder); + + testCase.verifyEqual(secondPaths, firstPaths) + testCase.verifyEqual(secondContents, firstContents) + testCase.verifyFalse(contains( ... + fileread(fullfile(targetFolder, "upload.m")), "external.mytoolbox")) + end + + function testFileRemovedFromSourceIsRemovedFromTarget(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + delete(fullfile(repositoryFolder, testCase.SourceFolder, "toolboxdir.m")) + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + testCase.verifyFalse(isfile(fullfile(targetFolder, "toolboxdir.m"))) + testCase.verifyTrue(isfile(fullfile(targetFolder, "upload.m"))) + end + + function testFolderWithoutVendorInfoIsNotReplaced(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + ownFile = fullfile(targetFolder, "ownCode.m"); + writeText(ownFile, "x = 1;") + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder), ... + "MatBox:VendorPackage:TargetFolderNotVendored") + testCase.verifyTrue(isfile(ownFile)) + end + + function testErrorLeavesExistingCopyUnchanged(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + [pathsBefore, contentsBefore] = readFolder(targetFolder); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + ExcludeFiles="renamedUpstream.m"), ... + "MatBox:VendorPackage:ExcludedFileNotFound") + + [pathsAfter, contentsAfter] = readFolder(targetFolder); + testCase.verifyEqual(pathsAfter, pathsBefore) + testCase.verifyEqual(contentsAfter, contentsBefore) + end + + function testFailedMoveLeavesExistingCopyUnchanged(testCase) + % A parent folder without write permission makes the move of the + % new copy into it fail, after the copy has been prepared. + testCase.assumeFalse(ispc, ... + "A read-only folder on Windows still accepts new files.") + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + [pathsBefore, contentsBefore] = readFolder(targetFolder); + + parentFolder = fileparts(targetFolder); + fileattrib(parentFolder, "-w") + testCase.addTeardown(@() fileattrib(parentFolder, "+w")) + testCase.assumeFalse(mkdir(fullfile(parentFolder, "writeProbe")), ... + "The test user can write to folders without write permission.") + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder), ... + ?MException) + + [pathsAfter, contentsAfter] = readFolder(targetFolder); + testCase.verifyEqual(pathsAfter, pathsBefore) + testCase.verifyEqual(contentsAfter, contentsBefore) + end + + function testReplacementLeavesNoTemporaryFolders(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + [parentFolder, targetName] = fileparts(targetFolder); + folderListing = dir(parentFolder); + folderNames = setdiff(string({folderListing.name}), [".", ".."]); + testCase.verifyEqual(folderNames, targetName) + end + + function testFolderFromOtherRepositoryIsNotReplaced(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = testCase.getTargetFolder(); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + RepositoryUrl="https://github.com/owner/webprogress") + [pathsBefore, contentsBefore] = readFolder(targetFolder); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder, ... + RepositoryUrl="https://github.com/owner/otherpackage"), ... + "MatBox:VendorPackage:TargetFolderHasOtherSource") + + [pathsAfter, contentsAfter] = readFolder(targetFolder); + testCase.verifyEqual(pathsAfter, pathsBefore) + testCase.verifyEqual(contentsAfter, contentsBefore) + end + + function testFolderFromOtherSourceFolderIsNotReplaced(testCase) + repositoryFolder = testCase.createRepository(); + otherSourceFolder = "legacy/+webprogress"; + copyfile(fullfile(repositoryFolder, testCase.SourceFolder), ... + fullfile(repositoryFolder, otherSourceFolder)) + targetFolder = testCase.getTargetFolder(); + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + [pathsBefore, contentsBefore] = readFolder(targetFolder); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, otherSourceFolder, targetFolder), ... + "MatBox:VendorPackage:TargetFolderHasOtherSource") + + [pathsAfter, contentsAfter] = readFolder(targetFolder); + testCase.verifyEqual(pathsAfter, pathsBefore) + testCase.verifyEqual(contentsAfter, contentsBefore) + end + + function testSameNamespaceCopiesFilesUnchanged(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = fullfile(pwd, "project", "external", "+webprogress"); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + sourceFile = fullfile(repositoryFolder, testCase.SourceFolder, "upload.m"); + testCase.verifyEqual(fileread(fullfile(targetFolder, "upload.m")), ... + fileread(sourceFile)) + end + + function testOrdinaryTargetFolderErrors(testCase) + repositoryFolder = testCase.createRepository(); + targetFolder = fullfile(pwd, "project", "external", "webprogress"); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder), ... + "MatBox:VendorPackage:NamespaceMismatch") + end + + function testMissingSourceFolderErrors(testCase) + repositoryFolder = testCase.createRepository(); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, "src/+otherName", testCase.getTargetFolder()), ... + "MatBox:VendorPackage:SourceFolderNotFound") + end + + function testMissingLicenseFileErrors(testCase) + repositoryFolder = testCase.createRepository(); + + testCase.verifyError( ... + @() matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, testCase.getTargetFolder(), ... + LicenseFile="license.txt"), ... + "MatBox:VendorPackage:LicenseFileNotFound") + end + + function testBytesOutsideReferencesArePreserved(testCase) + % The comment holds multi-byte UTF-8 characters and the file has + % Windows line endings; both must come through byte for byte. + repositoryFolder = testCase.createRepository(); + sourceText = "% µm – ok" + char([13 10]) + "webprogress.upload(a)" + char([13 10]); + writeText(fullfile(repositoryFolder, testCase.SourceFolder, "scale.m"), sourceText) + targetFolder = testCase.getTargetFolder(); + + matbox.tasks.internal.vendorPackageFromFolder( ... + repositoryFolder, testCase.SourceFolder, targetFolder) + + expectedText = replace(sourceText, "webprogress.", testCase.TargetNamespace + "."); + testCase.verifyEqual(readBytes(fullfile(targetFolder, "scale.m")), ... + unicode2native(expectedText, "UTF-8")) + end + end + + methods (Test) % vendorPackage + function testTargetFolderOutsideProjectErrors(testCase) + testCase.verifyError( ... + @() matbox.tasks.vendorPackage(pwd, testCase.GitHubSourceUri, ... + "code/+matbox/+utility", "../+mytoolbox/+utility"), ... + "MatBox:VendorPackage:InvalidTargetFolder") + end + + function testVendorFromGitHub(testCase) + % Requires network access. + targetFolder = "src/+mytoolbox/+external/+utility"; + + vendorInfo = matbox.tasks.vendorPackage(pwd, testCase.GitHubSourceUri, ... + "code/+matbox/+utility", targetFolder, ... + ExcludeFiles="filewrite.m", Verbose=false); + + testCase.verifyEqual(vendorInfo.Source, "https://github.com/ehennestad/matbox") + testCase.verifyEqual(vendorInfo.Reference, "v0.11.0") + testCase.verifyEqual(vendorInfo.CommitID, testCase.GitHubCommitId) + testCase.verifyTrue(isfile(fullfile(pwd, targetFolder, "LICENSE"))) + testCase.verifyTrue(isfile(fullfile(pwd, targetFolder, "vendorinfo.json"))) + testCase.verifyFalse(isfile(fullfile(pwd, targetFolder, "filewrite.m"))) + testCase.verifySubstring( ... + fileread(fullfile(pwd, targetFolder, "getLatestMatlabReleaseForGitHubActions.m")), ... + "mytoolbox.external.utility.getLatestMatlabReleaseForGitHubActions()") + end + end + + methods (Access = private) + function repositoryFolder = createRepository(testCase) + % Create the files of a repository whose package is in the + % namespace SourceNamespace, as an unzipped download would be. + repositoryFolder = string(fullfile(pwd, "repository")); + packageFolder = fullfile(repositoryFolder, testCase.SourceFolder); + + uploadCode = join([ + "function upload(filePath, url)" + "%upload - Upload a file" + "% webprogress.upload(FILENAME,URL) uploads the file." + " monitorFcn = @(o) webprogress.Monitor(o{:});" + " error(""webprogress:upload:Failed"", ""Upload failed."")" + "end" + ], newline); + + writeText(fullfile(repositoryFolder, "LICENSE"), testCase.LicenseText) + writeText(fullfile(packageFolder, "upload.m"), uploadCode) + writeText(fullfile(packageFolder, testCase.ExcludedFile), "function toolboxdir(), end") + writeText(fullfile(packageFolder, "private", "mustBeValidUrl.m"), ... + "function mustBeValidUrl(~), end") + end + + function targetFolder = getTargetFolder(~) + % Full path of a folder that defines the namespace TargetNamespace + targetFolder = string(fullfile(pwd, "project", "src", ... + "+mytoolbox", "+external", "+webprogress")); + end + end +end + +function writeText(filePath, text) +% writeText - Write text to a UTF-8 file, creating its folder if needed + folderPath = fileparts(filePath); + if ~isfolder(folderPath) + mkdir(folderPath) + end + fileId = fopen(filePath, "w"); + fileCleanup = onCleanup(@() fclose(fileId)); + fwrite(fileId, unicode2native(text, "UTF-8"), "uint8"); +end + +function bytes = readBytes(filePath) +% readBytes - Read a file as a row vector of bytes + fileId = fopen(filePath, "r"); + fileCleanup = onCleanup(@() fclose(fileId)); + bytes = fread(fileId, [1, Inf], "*uint8"); +end + +function [relativePaths, contents] = readFolder(folderPath) +% readFolder - Get the relative paths and the bytes of all files below a folder + fileListing = dir(fullfile(folderPath, "**", "*")); + fileListing([fileListing.isdir]) = []; + filePaths = sort(string(fullfile({fileListing.folder}, {fileListing.name}))); + + relativePaths = erase(filePaths, string(folderPath) + filesep); + contents = cell(size(filePaths)); + for i = 1:numel(filePaths) + contents{i} = readBytes(filePaths(i)); + end +end